Back to Insights
PersonalNext.jsGroqLlama 3.3ChatbotTypeScriptReactAIFastAPIWebSocketTailwind CSS

How to Build an AI FAQ Chatbot with Groq, Llama 3.3 & Next.js (Typewriter Effect, Clickable Links)

2026-07-1810 min read

The Project

I built an AI FAQ chatbot for my portfolio site — a floating chat bubble that answers questions about my skills, projects, experience, and availability. It uses Groq's Llama 3.3 70B under the hood, streams responses word-by-word with a typewriter effect, and renders markdown with clickable links — all inside a compact, mobile-friendly widget.

The goal wasn't just "add a chatbot." It was: make visitors feel like they're talking to me directly, without me having to be online.

Architecture

User types question
       ↓
  Next.js API Route (/api/chat)
       ↓
  Builds system prompt from portfolio data
       ↓
  Groq API (Llama 3.3 70B, 0.3 temperature)
       ↓
  Returns structured response
       ↓
  Typewriter effect renders text word-by-word
       ↓
  Clickable links, bold text, bullet points parsed

System Prompt Engineering

The key to making the chatbot useful was the system prompt. Instead of hardcoding answers, I built a dynamic context from the actual portfolio data:

export function buildPortfolioContext(): string {
  return `You are a friendly AI assistant for Muiz Ud Din's portfolio website. ...
  
  ## About Muiz
  - Name: ${profile.name}
  - Title: ${profile.title}
  - Location: ${profile.location}
  - Email: ${profile.email}
  
  ## Skills
  ${skills.map(...)}
  
  ## Projects
  ${projects.map(...)}
  
  ## Testimonials
  ${testimonials.map(...)}
  
  Guidelines:
  - Keep initial answers VERY short — 1-3 sentences
  - Always end with a follow-up question
  - Never dump all info at once`
}

Every time someone asks a question, the API route builds this context from the live portfolio data — so if I update my skills or add a project, the chatbot automatically knows about it. No code changes needed.

The Typewriter Effect

Instead of showing the full response at once, the chatbot reveals text character by character:

function TypewriterMessage({ text, onDone, scrollRef }) {
  const [displayed, setDisplayed] = useState("")

  useEffect(() => {
    let i = 0
    const interval = setInterval(() => {
      i++
      setDisplayed(text.slice(0, i))
      scrollRef.current?.scrollIntoView({ behavior: "smooth" })
      if (i >= text.length) {
        clearInterval(interval)
        onDone()
      }
    }, 15)  // ~15ms per character
    return () => clearInterval(interval)
  }, [text])
  
  return <BotMessage text={displayed} />
}

This makes the bot feel alive — users see the response being "written" in real-time. Combined with auto-scroll, the chat feels like a conversation, not a search result.

Structured Response Rendering

Raw markdown looks bad in a chat bubble. I built a custom renderer that parses:

  • Bold text<strong> tags
  • Bullet points (- ) → Styled list items with • markers
  • URLs (https://...) → Clickable <a> tags opening in new tabs
  • Line breaks → Proper spacing between paragraphs
function renderInline(text: string) {
  // Split by bold markers
  const parts = text.split(/(\*\*.*?\*\*)/)
  return parts.map((part, i) => {
    if (part.startsWith("**") && part.endsWith("**")) {
      return <strong key={i}>{part.slice(2, -2)}</strong>
    }
    // Detect and linkify URLs
    const urlParts = part.split(/(https?:\/\/[^\s)]+)/g)
    return urlParts.map((seg, j) =>
      seg.match(urlRegex) ? (
        <a key={j} href={seg} target="_blank" rel="noopener noreferrer">
          {seg}
        </a>
      ) : seg
    )
  })
}

Links like https://github.com/MUIZ-UDDIN become purple, underlined, and clickable — exactly what you'd want when someone asks "where can I see your work?"

UX Details That Matter

Outside-click to close — A transparent backdrop overlay closes the chat when you tap anywhere outside it. On mobile, this is essential.

Animated loading dots — While waiting for the API, three bouncing dots appear: "Thinking..."

Error resilience — If the Groq API times out, the bot shows a friendly fallback message and suggests emailing directly. No broken experiences.

Mobile-first — The chat panel uses w-[calc(100vw-3rem)] so it never overflows the viewport, with proper padding on all screen sizes.

Retry Logic

Groq's API can be slow or timeout occasionally. The API route automatically retries up to 2 times with exponential backoff:

async function fetchWithRetry(url: string, options: RequestInit, retries = 2) {
  for (let i = 0; i <= retries; i++) {
    try {
      return await fetch(url, options)
    } catch (err) {
      if (i === retries) throw err
      await new Promise(r => setTimeout(r, 1000 * (i + 1)))
    }
  }
}

This makes the chatbot reliable even when the API has transient failures.

What I Learned

  1. Typewriter effect changes perceived speed — Even if the API takes 3 seconds, the character-by-character reveal makes it feel faster than a 1-second wait with no animation.

  2. Dynamic system prompts beat hardcoded Q&A — Building the prompt from live data means the chatbot stays in sync with the portfolio automatically.

  3. Custom markdown rendering > raw text — In a chat UI, structured formatting (bold, lists, links) makes responses scannable. Raw text dumps feel overwhelming.

  4. Outside-click-to-close is non-negotiable on mobile — Without it, users have to target a small X button, which is frustrating on a phone.

  5. Fallback messages build trust — When the API fails, a friendly "reach out via email" message is better than a generic error. Users appreciate being pointed to a real person.

Frequently Asked Questions

Quick answers to common questions about this project.

Use a `useEffect` with `setInterval` that increments a character counter. Each tick adds one more character to the displayed string. Auto-scroll the chat container using a ref and `scrollIntoView({ behavior: 'smooth' })` on each update.

Wrap the API call in a retry function with exponential backoff (2 retries, 1s/2s delays). If all retries fail, return a friendly fallback message like 'I'm having trouble connecting. Please try again or reach out via email.' Never show raw error text to users.