Project Structure
The main site code is inapps/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
Fromapps/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
- Learn about AI Agents integration
- Explore Testing strategies
- Check Deployment guide