Skip to main content
Answer Overflow’s frontend is built with Next.js 14+ using the App Router, React Server Components, and Convex for real-time data.

Project Structure

The main site code is in apps/main-site/src/:
apps/main-site/src/
├── app/
│   ├── (main-site)/        # Main site routes
│   ├── [domain]/           # Tenant-specific routes
│   └── layout.tsx          # Root layout
├── components/             # Reusable components
├── lib/                    # Utilities and helpers
└── styles/                 # Global styles

App Router Patterns

Route Organization

Answer Overflow uses route groups for organization:
app/
├── (main-site)/           # Main answeroverflow.com routes
│   ├── page.tsx           # Homepage
│   ├── chat/              # AI chat interface
│   ├── dashboard/         # User dashboard
│   └── layout.tsx
└── [domain]/              # Custom domain routes (e.g., community.com)
    ├── (content)/
    │   ├── m/[messageId]/  # Message pages
    │   └── c/[channelId]/  # Channel pages
    └── layout.tsx

Dynamic Routes

Message pages use dynamic segments:
// app/(main-site)/m/[messageId]/page.tsx
import { Suspense } from "react";

type Props = {
  params: { messageId: string };
};

export default function MessagePage({ params }: Props) {
  return (
    <Suspense fallback={<MessageSkeleton />}>
      <MessageContent messageId={params.messageId} />
    </Suspense>
  );
}

Server Components

Use Server Components for data fetching and SEO.

Fetching Data

Server Components can directly query Convex:
import { preloadQuery } from "convex/nextjs";
import { api } from "@packages/database/convex/_generated/api";

export default async function ChannelPage({
  params,
}: {
  params: { channelId: string };
}) {
  // Preload data on the server
  const preloadedChannel = await preloadQuery(
    api.public.channels.getChannel,
    { channelId: BigInt(params.channelId) }
  );

  return (
    <main>
      <ChannelHeader preloadedData={preloadedChannel} />
      <ChannelMessages channelId={params.channelId} />
    </main>
  );
}

Metadata Generation

Generate dynamic metadata for SEO:
import type { Metadata } from "next";
import { fetchQuery } from "convex/nextjs";
import { api } from "@packages/database/convex/_generated/api";

export async function generateMetadata({
  params,
}: {
  params: { messageId: string };
}): Promise<Metadata> {
  const message = await fetchQuery(
    api.public.messages.getMessagePageHeaderData,
    { messageId: BigInt(params.messageId) }
  );

  if (!message) {
    return { title: "Message not found" };
  }

  return {
    title: message.title,
    description: message.content.slice(0, 160),
    openGraph: {
      title: message.title,
      description: message.content.slice(0, 160),
      images: [message.authorAvatar],
    },
  };
}

Client Components

Use Client Components for interactivity.

Convex Hooks

Query data reactively with Convex hooks:
"use client";

import { useQuery } from "convex/react";
import { api } from "@packages/database/convex/_generated/api";

export function MessageList({ channelId }: { channelId: string }) {
  const messages = useQuery(api.public.messages.getMessages, {
    channelId: BigInt(channelId),
    after: 0n,
    paginationOpts: { numItems: 50, cursor: null },
  });

  if (!messages) {
    return <LoadingSpinner />;
  }

  return (
    <div className="space-y-4">
      {messages.page.map((message) => (
        <MessageCard key={message.id.toString()} message={message} />
      ))}
    </div>
  );
}

Pagination

Implement cursor-based pagination:
"use client";

import { usePaginatedQuery } from "convex/react";
import { api } from "@packages/database/convex/_generated/api";

export function PaginatedMessages({ channelId }: { channelId: string }) {
  const { results, status, loadMore } = usePaginatedQuery(
    api.public.messages.getMessages,
    { channelId: BigInt(channelId), after: 0n },
    { initialNumItems: 20 }
  );

  return (
    <div>
      {results.map((message) => (
        <MessageCard key={message.id.toString()} message={message} />
      ))}
      
      {status === "CanLoadMore" && (
        <button onClick={() => loadMore(20)}>Load More</button>
      )}
    </div>
  );
}

Mutations

Modify data with Convex mutations:
"use client";

import { useMutation } from "convex/react";
import { api } from "@packages/database/convex/_generated/api";

export function MarkSolutionButton({ messageId }: { messageId: string }) {
  const markSolution = useMutation(api.authenticated.threads.markSolution);
  const [isPending, setIsPending] = useState(false);

  const handleClick = async () => {
    setIsPending(true);
    try {
      await markSolution({
        solutionId: BigInt(messageId),
      });
      toast.success("Marked as solution!");
    } catch (error) {
      toast.error("Failed to mark solution");
    } finally {
      setIsPending(false);
    }
  };

  return (
    <button onClick={handleClick} disabled={isPending}>
      {isPending ? "Marking..." : "Mark Solution"}
    </button>
  );
}

Streaming and Suspense

Streaming UI

From apps/main-site/src/app/(main-site)/chat/page.tsx:
import { Suspense } from "react";

export default function ChatPage() {
  return (
    <Suspense fallback={<ChatPageSkeleton />}>
      <ChatPageHandler />
    </Suspense>
  );
}

function ChatPageSkeleton() {
  return (
    <div className="flex-1 flex items-center justify-center">
      <div className="animate-pulse text-muted-foreground">
        Loading chat...
      </div>
    </div>
  );
}

AI Streaming Responses

Stream AI responses for better UX:
"use client";

import { useChat } from "@packages/agent/react";

export function ChatInterface() {
  const { messages, input, handleInputChange, handleSubmit, isLoading } =
    useChat({
      api: "/api/chat",
    });

  return (
    <div className="flex flex-col h-full">
      <div className="flex-1 overflow-y-auto">
        {messages.map((message) => (
          <div key={message.id} className={`message ${message.role}`}>
            {message.content}
          </div>
        ))}
      </div>

      <form onSubmit={handleSubmit} className="p-4">
        <input
          value={input}
          onChange={handleInputChange}
          placeholder="Ask a question..."
          disabled={isLoading}
        />
      </form>
    </div>
  );
}

State Management

URL State with nuqs

Manage URL state declaratively:
"use client";

import { parseAsString, useQueryState } from "nuqs";

export function SearchPage() {
  const [query, setQuery] = useQueryState("q", parseAsString);
  const [filter, setFilter] = useQueryState("filter", parseAsString);

  return (
    <div>
      <input
        value={query ?? ""}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="Search..."
      />
      
      <select value={filter ?? "all"} onChange={(e) => setFilter(e.target.value)}>
        <option value="all">All</option>
        <option value="solved">Solved</option>
        <option value="unsolved">Unsolved</option>
      </select>
    </div>
  );
}

React Context

For component-local state:
"use client";

import { createContext, useContext, useState } from "react";

type ChatContextType = {
  threadId: string | null;
  setThreadId: (id: string | null) => void;
};

const ChatContext = createContext<ChatContextType | null>(null);

export function ChatStateProvider({ children }: { children: React.ReactNode }) {
  const [threadId, setThreadId] = useState<string | null>(null);

  return (
    <ChatContext.Provider value={{ threadId, setThreadId }}>
      {children}
    </ChatContext.Provider>
  );
}

export function useChatContext() {
  const context = useContext(ChatContext);
  if (!context) throw new Error("useChatContext must be used within ChatStateProvider");
  return context;
}

Styling

Answer Overflow uses Tailwind CSS with shadcn/ui components.

Component Styling

import { cn } from "@packages/ui/lib/utils";

export function MessageCard({
  message,
  className,
}: {
  message: Message;
  className?: string;
}) {
  return (
    <div
      className={cn(
        "rounded-lg border bg-card p-4 shadow-sm",
        "hover:bg-accent transition-colors",
        className
      )}
    >
      <div className="flex items-start gap-3">
        <Avatar src={message.authorAvatar} />
        <div className="flex-1">
          <p className="font-semibold">{message.authorName}</p>
          <p className="text-sm text-muted-foreground">{message.content}</p>
        </div>
      </div>
    </div>
  );
}

Responsive Design

export function DashboardLayout({ children }: { children: React.ReactNode }) {
  return (
    <div className="flex h-screen">
      {/* Sidebar: hidden on mobile, visible on desktop */}
      <aside className="hidden md:flex md:w-64 border-r">
        <Sidebar />
      </aside>

      {/* Main content */}
      <main className="flex-1 overflow-y-auto">
        <div className="container mx-auto p-4 md:p-6 lg:p-8">
          {children}
        </div>
      </main>
    </div>
  );
}

Performance Optimization

Code Splitting

Dynamically import heavy components:
import dynamic from "next/dynamic";

const HeavyChart = dynamic(() => import("@/components/chart"), {
  ssr: false,
  loading: () => <ChartSkeleton />,
});

export function Dashboard() {
  return (
    <div>
      <h1>Dashboard</h1>
      <HeavyChart data={data} />
    </div>
  );
}

Image Optimization

import Image from "next/image";

export function ServerIcon({ server }: { server: Server }) {
  const iconUrl = server.icon
    ? `https://cdn.discordapp.com/icons/${server.discordId}/${server.icon}.png`
    : "/default-server.png";

  return (
    <Image
      src={iconUrl}
      alt={server.name}
      width={48}
      height={48}
      className="rounded-full"
    />
  );
}

Troubleshooting

Hydration Mismatches

Ensure server and client render the same:
// Wrong: Date.now() differs between server and client
<p>Current time: {Date.now()}</p>

// Correct: Use useEffect for client-only rendering
const [time, setTime] = useState<number | null>(null);

useEffect(() => {
  setTime(Date.now());
}, []);

return <p>Current time: {time ?? "Loading..."}</p>;

Convex Connection Issues

Check your Convex URL and deployment:
# Verify environment variables
echo $NEXT_PUBLIC_CONVEX_URL

# Should output: https://your-deployment.convex.cloud

BigInt Serialization

Next.js can’t serialize BigInt by default:
// Wrong: Passing BigInt directly
<Component messageId={message.id} />

// Correct: Convert to string
<Component messageId={message.id.toString()} />

// In component:
const messageId = BigInt(props.messageId);

Next Steps