Building AI-Powered Chat Features with Google Generative AI and Next.js
Implement production-ready AI chatbots using Google Generative AI (Gemini) with Next.js, streaming responses, context management, and prompt engineering.
Building AI-Powered Chat Features with Google Generative AI and Next.js
AI-powered chat interfaces have become a standard feature in modern web applications — from customer support to interactive portfolios. Having built a production AI chatbot for my portfolio using Google Generative AI (Gemini), I've refined patterns for streaming responses, managing conversation context, and crafting system prompts that produce consistently high-quality answers. This guide walks through the complete implementation.
Architecture Overview
The architecture is straightforward:
Client (React) → API Route (Next.js) → Google Generative AI (Gemini)
↑ ↓
└──────── Streaming Response ────────────────┘
Key decisions:
- Server-side API calls — API keys never reach the client
- Streaming — Responses appear word-by-word for better UX
- Conversation memory — Previous messages provide context for follow-ups
- System prompts — Define the AI's persona and knowledge boundaries
Server Setup: API Route
Google Generative AI Integration
// app/api/chat/route.ts
import { GoogleGenerativeAI } from '@google/generative-ai';
import { NextRequest } from 'next/server';
const genAI = new GoogleGenerativeAI(process.env.GOOGLE_AI_API_KEY!);
const SYSTEM_PROMPT = `You are an AI assistant embedded in a developer portfolio website.
You represent the developer and should answer questions in first person.
Key facts about the developer:
- Senior Frontend Developer with 4+ years of experience
- Specializes in React, Next.js, TypeScript, and React Native
- Has built production applications including multi-tenant SaaS platforms
- Expertise in performance optimization, SEO, and responsive design
- Available for freelance work
Rules:
- Answer questions about skills, experience, and availability
- Be concise and professional
- If asked about topics outside the developer's expertise, acknowledge it honestly
- Never fabricate projects or experience
- Keep responses under 200 words unless more detail is requested`;
export async function POST(request: NextRequest) {
try {
const { messages } = await request.json();
if (!messages || !Array.isArray(messages)) {
return Response.json({ error: 'Messages array required' }, { status: 400 });
}
// Limit conversation history to prevent token overflow
const recentMessages = messages.slice(-10);
const model = genAI.getGenerativeModel({
model: 'gemini-1.5-flash',
systemInstruction: SYSTEM_PROMPT,
});
const chat = model.startChat({
history: recentMessages.slice(0, -1).map((msg: { role: string; content: string }) => ({
role: msg.role === 'user' ? 'user' : 'model',
parts: [{ text: msg.content }],
})),
});
const lastMessage = recentMessages[recentMessages.length - 1];
const result = await chat.sendMessageStream(lastMessage.content);
// Stream the response
const encoder = new TextEncoder();
const stream = new ReadableStream({
async start(controller) {
try {
for await (const chunk of result.stream) {
const text = chunk.text();
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ text })}\n\n`));
}
controller.enqueue(encoder.encode('data: [DONE]\n\n'));
controller.close();
} catch (error) {
controller.error(error);
}
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
} catch (error) {
console.error('Chat API error:', error);
return Response.json({ error: 'Failed to process request' }, { status: 500 });
}
}
Client Implementation
Chat Hook with Streaming
// hooks/useChat.ts
'use client';
import { useState, useCallback, useRef } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
}
export function useChat() {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(async (content: string) => {
const userMessage: Message = {
id: crypto.randomUUID(),
role: 'user',
content,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
const assistantMessage: Message = {
id: crypto.randomUUID(),
role: 'assistant',
content: '',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
try {
abortControllerRef.current = new AbortController();
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMessage].map((m) => ({
role: m.role,
content: m.content,
})),
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) throw new Error('Chat request failed');
const reader = response.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error('No response body');
let accumulated = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split('\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
accumulated += parsed.text;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: accumulated }
: msg
)
);
} catch {
// Skip malformed chunks
}
}
}
}
} catch (error) {
if ((error as Error).name === 'AbortError') return;
setMessages((prev) =>
prev.map((msg) =>
msg.id === assistantMessage.id
? { ...msg, content: 'Sorry, something went wrong. Please try again.' }
: msg
)
);
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
}, [messages]);
const stopGeneration = useCallback(() => {
abortControllerRef.current?.abort();
setIsLoading(false);
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
return { messages, isLoading, sendMessage, stopGeneration, clearMessages };
}
Chat UI Component
'use client';
import { useState, useRef, useEffect } from 'react';
import { useChat } from '@/hooks/useChat';
import { motion, AnimatePresence } from 'framer-motion';
import { IoSend, IoClose, IoChatbubbleEllipses } from 'react-icons/io5';
const QUICK_QUESTIONS = [
'What technologies do you work with?',
'Are you available for freelance projects?',
'Tell me about your experience.',
'What kind of projects have you built?',
];
export function Chatbot() {
const [isOpen, setIsOpen] = useState(false);
const [input, setInput] = useState('');
const { messages, isLoading, sendMessage, stopGeneration } = useChat();
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!input.trim() || isLoading) return;
sendMessage(input.trim());
setInput('');
};
return (
<>
{/* Toggle Button */}
<button
onClick={() => setIsOpen(!isOpen)}
className="fixed bottom-6 right-6 z-50 w-14 h-14 rounded-full bg-cyan-500
text-white shadow-lg hover:bg-cyan-600 transition-colors
flex items-center justify-center"
aria-label={isOpen ? 'Close chat' : 'Open chat'}
>
{isOpen ? <IoClose size={24} /> : <IoChatbubbleEllipses size={24} />}
</button>
{/* Chat Window */}
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
className="fixed bottom-24 right-6 z-50 w-[380px] h-[500px]
bg-gray-900 rounded-2xl shadow-2xl border border-gray-800
flex flex-col overflow-hidden"
>
{/* Header */}
<div className="p-4 border-b border-gray-800 bg-gray-900/80 backdrop-blur">
<h3 className="font-semibold text-white">Chat with me</h3>
<p className="text-xs text-gray-400">AI-powered — ask anything about my work</p>
</div>
{/* Messages */}
<div className="flex-1 overflow-y-auto p-4 space-y-3">
{messages.length === 0 && (
<div className="space-y-2">
<p className="text-sm text-gray-400">Try asking:</p>
{QUICK_QUESTIONS.map((q) => (
<button
key={q}
onClick={() => sendMessage(q)}
className="block w-full text-left text-sm p-2 rounded-lg
bg-gray-800 hover:bg-gray-700 text-gray-300 transition"
>
{q}
</button>
))}
</div>
)}
{messages.map((msg) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
>
<div
className={`max-w-[80%] px-3 py-2 rounded-xl text-sm ${
msg.role === 'user'
? 'bg-cyan-600 text-white'
: 'bg-gray-800 text-gray-200'
}`}
>
{msg.content || (
<span className="inline-block w-2 h-4 bg-gray-500 animate-pulse" />
)}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{/* Input */}
<form onSubmit={handleSubmit} className="p-3 border-t border-gray-800">
<div className="flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Type a message..."
className="flex-1 bg-gray-800 text-white rounded-lg px-3 py-2 text-sm
placeholder:text-gray-500 focus:outline-none focus:ring-1
focus:ring-cyan-500"
/>
<button
type="submit"
disabled={!input.trim() || isLoading}
className="p-2 rounded-lg bg-cyan-500 text-white disabled:opacity-50
hover:bg-cyan-600 transition"
>
<IoSend size={18} />
</button>
</div>
</form>
</motion.div>
)}
</AnimatePresence>
</>
);
}
Prompt Engineering Best Practices
1. Context Injection
Embed structured data into the system prompt for accurate responses:
const portfolioContext = {
projects: [
{ name: 'Liko', tech: 'GSAP, Three.js, React', description: 'Creative agency with 12 homepage variations' },
{ name: 'CertsLibrary', tech: 'Next.js, OpenAI', description: 'Multi-tenant SaaS with RBAC' },
],
skills: ['React', 'Next.js', 'TypeScript', 'React Native', 'Node.js'],
experience: '4+ years',
};
const SYSTEM_PROMPT = `
You are the AI assistant for a developer portfolio.
Here is the developer's data: ${JSON.stringify(portfolioContext)}
Answer based ONLY on this data. If asked about something not covered, say so honestly.
`;
2. Temperature and Safety Settings
const model = genAI.getGenerativeModel({
model: 'gemini-1.5-flash',
systemInstruction: SYSTEM_PROMPT,
generationConfig: {
temperature: 0.7, // Balanced creativity
topP: 0.9,
topK: 40,
maxOutputTokens: 500, // Keep responses focused
},
safetySettings: [
{ category: 'HARM_CATEGORY_HARASSMENT', threshold: 'BLOCK_MEDIUM_AND_ABOVE' },
{ category: 'HARM_CATEGORY_HATE_SPEECH', threshold: 'BLOCK_MEDIUM_AND_ABOVE' },
],
});
3. Rate Limiting
Protect your API key with request throttling:
// lib/rate-limit.ts
const rateLimit = new Map<string, { count: number; resetTime: number }>();
export function checkRateLimit(ip: string, maxRequests = 20, windowMs = 60000): boolean {
const now = Date.now();
const record = rateLimit.get(ip);
if (!record || now > record.resetTime) {
rateLimit.set(ip, { count: 1, resetTime: now + windowMs });
return true;
}
if (record.count >= maxRequests) return false;
record.count += 1;
return true;
}
// Usage in API route
export async function POST(request: NextRequest) {
const ip = request.headers.get('x-forwarded-for') || 'unknown';
if (!checkRateLimit(ip)) {
return Response.json({ error: 'Rate limit exceeded' }, { status: 429 });
}
// ... rest of handler
}
Error Handling and Fallbacks
// Graceful degradation when AI is unavailable
const FALLBACK_RESPONSES: Record<string, string> = {
default: "I'm temporarily unavailable. Please email me at hello@example.com.",
skills: 'I specialize in React, Next.js, TypeScript, and React Native.',
availability: "I'm available for freelance projects. Let's discuss your requirements!",
};
function getFallbackResponse(query: string): string {
const lowerQuery = query.toLowerCase();
if (lowerQuery.includes('skill') || lowerQuery.includes('tech')) return FALLBACK_RESPONSES.skills;
if (lowerQuery.includes('available') || lowerQuery.includes('hire')) return FALLBACK_RESPONSES.availability;
return FALLBACK_RESPONSES.default;
}
Key Takeaways
- Server-side API calls — Never expose AI API keys to the client
- Streaming responses — SSE provides real-time typing feedback
- Conversation history limit — Trim to last 10 messages to manage token costs
- System prompts define persona — Embed structured portfolio data for accuracy
- Rate limiting is mandatory — Protect against API key abuse
- Graceful fallbacks — Always have static responses ready when AI fails
AI chatbots transform static portfolios into interactive experiences. With Google Generative AI and Next.js, you can build one in a day that genuinely represents your professional capabilities.