
Putting It All Together: A Complete Next.js Mini-Agent Project Recap and Full Integration Guide
In our previous architectural guide on where Client Components and Server Components belong in a Next.js AI agent, we separated our server-side Supabase session hydration (page.tsx and SessionSidebar.tsx) from our interactive browser leaf nodes using the React slot pattern. Over the past thirteen posts (Posts 13 to 25), we have built every individual subsystem of DevPulse—our Level 2 semi-autonomous issue triage and documentation agent—piece by piece. Yet having fourteen separate TypeScript files in a repository is not the same as shipping a cohesive product; if the human-in-the-loop ticket approval button only toggles local React state without updating the database, or if finishing a new chat turn fails to refresh the server-rendered session sidebar, the seams between our modules will show.
To assemble a complete, production-ready mini-agent in Next.js, you unify four architectural layers into a single execution loop: Edge Middleware sliding-window rate limiting at the perimeter, a parallel-hydrated Server Component workspace at the root, a multi-step tool-calling streaming pipeline (Claude 3.5 Sonnet / GPT-4o + Supabase JSONB persistence) in /api/chat, and a type-safe React useChat leaf connected to a dedicated Server Action for Level 2 human-in-the-loop write approvals. Wiring these layers together gives you a deployable full-stack agent that researches internal docs, probes live GitHub issues, drafts bug tickets with human confirmation, and survives page refreshes without losing state.
This post concludes Section B (Posts 13–26: Next.js/React Implementation) of our 62-part series before we expand into Python, FastAPI, Docker, and DevOps workflows in Section C.
The Full-Stack Anatomy of DevPulse: How Posts 13–25 Connect
When you step back and inspect the complete repository we have constructed since Post 12's initial roadmap, every file serves a distinct purpose in the request lifecycle:
src/middleware.ts(Post 22): Intercepts incomingPOST /api/chattraffic at the network edge, enforcing a 5-turns-per-minute sliding window burst cap and a 40-turns-per-day quota via Upstash Redis.src/app/page.tsx&SessionSidebar.tsx(Post 25): Runs parallelPromise.allqueries against Supabase on the server to pre-render the session navigation list with zero client JavaScript and passinitialMessagesdown to the chat leaf.src/lib/agent/blueprint.ts&type-contracts.ts(Posts 12 & 21): Defines our single source of truth for Zod tool schemas, discriminated union return envelopes (ok: truevs.ok: false), and React type-predicate guards (isToolInvocationFor).src/lib/agent/custom-github-tool.ts&vercel-tools.ts(Posts 17 & 19): Connects our agent to internal documentation search, repository issue lookup, live upstream GitHub REST API probing (with a 5-secondAbortSignal.timeoutand 180-token DTO shaper), and Level 2 ticket drafting.src/app/api/chat/route.ts&history-store.ts(Posts 15, 20, & 24): Streams token deltas andagent-phaseevents in ~300ms while persisting completed turns,tool_invocationsJSONB arrays, and token telemetry insideonFinish.src/components/DevPulseStreamChat.tsx&AgentThinkingTimeline.tsx(Posts 16 & 20): Manages the client-side streaming loop, threshold-gated auto-scrolling (useSmartAutoScroll), and zero-CLS collapsible thinking stages.

Here is the master end-to-end architecture diagram showing how a single user turn travels through every module we built across Section B:
mermaid flowchart TD User([Developer in Browser]) -->|1. Load Session| PageRSC[page.tsx Server Component] PageRSC -->|Parallel Promise.all| SupaRead[(Supabase PostgreSQL)] PageRSC -->|Hydrate Slots and Props| UI[DevPulseStreamChat Client Leaf] User -->|2. Submit Triage Prompt| Edge[middleware.ts Edge Rate Limiter] Edge -->|Exceeded Quota| Banner429[RateLimitCooldownBanner 429 UI] Edge -->|Allowed| ChatAPI[POST /api/chat Route Handler] ChatAPI -->|Sanitize 10-Turn Window| StreamEngine[stream-client.ts LLM Loop] StreamEngine -->|Step 1..N Tool Calls| Tools[vercel-tools.ts + GitHub API] Tools -->|180-Token Shaped DTO| StreamEngine StreamEngine -->|Stream Frames + Phase Events| UI StreamEngine -->|onFinish Batch Insert| SupaRead User -->|3. Click Approve Ticket| ApproveSA[approve-ticket.ts Server Action] ApproveSA -->|Idempotent Upsert| SupaRead
Architecture Flow Summary: Browser Request → Next.js Server Component Hydration → Edge Middleware Rate Limiter → Streaming Route Handler & Multi-Step Zod Tools → Non-Blocking Supabase JSONB Persistence & Level 2 Approval Server Action.
Step 1: Closing the Loop on Level 2 Autonomy (approve-ticket.ts)
Back in Post 17, when our agent invoked the draftIssueTicket tool, our React UI showed an 'Approve & Save Ticket' button that only flipped a local React state boolean (setApprovedDrafts). If you refreshed the page, that approval state vanished, and no ticket was ever written to the database.
Let us build the real server-side mutation in src/app/actions/approve-ticket.ts. First, create an agent_tickets table in your Supabase SQL Editor so approved bug reports are permanently recorded with a unique constraint on tool_call_id. Next, write the 'use server' action that validates the payload with our draftTicketSchema from blueprint.ts, enforces our Server Action rate limit from Post 22, and upserts the confirmed ticket into Supabase:
`typescript // src/app/actions/approve-ticket.ts 'use server';
import { z } from 'zod'; import { createClient } from '@supabase/supabase-js'; import { draftTicketSchema } from '@/lib/agent/blueprint'; import { assertServerActionRateLimit } from '@/lib/agent/server-action-guard';
const approveActionSchema = z.object({ sessionKey: z.string().min(8), toolCallId: z.string().min(4), ticket: draftTicketSchema, });
export interface ApproveTicketResponse { ok: boolean; ticketId?: string; errorMessage?: string; }
export async function approveAndPersistTicket(
rawInput: unknown
): Promise {
const rateCheck = await assertServerActionRateLimit();
if (!rateCheck.allowed) {
return {
ok: false,
errorMessage: Rate limit active. Please wait ${rateCheck.retryAfterSeconds}s.,
};
}
const parsed = approveActionSchema.safeParse(rawInput); if (!parsed.success) { return { ok: false, errorMessage: parsed.error.issues.map((i) => i.message).join('; '), }; }
const url = process.env.SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!url || !key) { return { ok: false, errorMessage: 'Server database credentials missing.' }; }
const supabase = createClient(url, key, { auth: { persistSession: false, autoRefreshToken: false }, });
const { sessionKey, toolCallId, ticket } = parsed.data;
const { data, error } = await supabase .from('agent_tickets') .upsert( { session_key: sessionKey, tool_call_id: toolCallId, title: ticket.title, severity: ticket.severity, component: ticket.component, reproduction_steps: ticket.reproductionSteps, }, { onConflict: 'tool_call_id' } ) .select('id') .single();
if (error || !data) { return { ok: false, errorMessage: error?.message ?? 'Failed to save approved ticket.', }; }
return { ok: true, ticketId: data.id, }; } `
Notice how upsert(..., { onConflict: 'tool_call_id' }) guarantees idempotency: even if a user double-clicks the approval button on a slow connection, PostgreSQL will never create duplicate bug tickets for the same toolCallId.
Step 2: The Final Unified /api/chat/route.ts Controller
Let us review the complete, integrated src/app/api/chat/route.ts endpoint that combines our Zod validation, 10-turn history sanitization, multi-step createAgentTextStream engine, real-time agent-phase data events, and onFinish Supabase batch persistence:
`typescript // src/app/api/chat/route.ts import { NextRequest, NextResponse } from 'next/server'; import { createDataStreamResponse, type ToolInvocation } from 'ai'; import { z } from 'zod'; import { defaultDevPulseConfig } from '@/lib/agent/blueprint'; import { type ChatMessage } from '@/lib/agent/llm-client'; import { createAgentTextStream } from '@/lib/agent/stream-client'; import { persistCompletedAgentTurn } from '@/lib/agent/history-store';
export const maxDuration = 60;
const chatRouteSchema = z.object({ sessionId: z.string().min(8), modelId: z .enum(['gpt-4o', 'claude-3-5-sonnet-latest']) .default('claude-3-5-sonnet-latest'), messages: z .array( z.object({ role: z.enum(['user', 'assistant', 'system']), content: z.string(), }) ) .min(1), });
export interface MessageTelemetryAnnotation { modelUsed: string; promptTokens: number; completionTokens: number; latencyMs: number; stepCount: number; toolsCalled: string[]; }
export async function POST(req: NextRequest) { try { const body = await req.json().catch(() => null); const parsed = chatRouteSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: 'Invalid chat request payload.' },
{ status: 400 }
);
}
const { sessionId, modelId, messages } = parsed.data;
const lastUserTurn = [...messages].reverse().find((m) => m.role === 'user');
const cleanMessages: ChatMessage[] = messages.filter(
(m): m is { role: 'user' | 'assistant'; content: string } =>
(m.role === 'user' || m.role === 'assistant') &&
m.content.trim().length > 0
);
return createDataStreamResponse({
execute: (dataStream) => {
dataStream.writeData({
type: 'agent-phase',
phase: 'reasoning',
detail: `Routing query with ${modelId}...`,
timestamp: Date.now(),
});
const result = createAgentTextStream(
cleanMessages,
{
...defaultDevPulseConfig,
modelId,
},
async (telemetry, completionDetails) => {
dataStream.writeMessageAnnotation({
modelUsed: telemetry.modelUsed,
promptTokens: telemetry.promptTokens,
completionTokens: telemetry.completionTokens,
latencyMs: telemetry.latencyMs,
stepCount: telemetry.stepCount,
toolsCalled: telemetry.toolsCalled,
});
if (lastUserTurn) {
await persistCompletedAgentTurn({
externalSessionKey: sessionId,
modelId,
userPrompt: lastUserTurn.content,
assistantReply: completionDetails.text,
toolInvocations: completionDetails.toolInvocations as ToolInvocation[],
telemetry,
});
}
}
);
result.mergeIntoDataStream(dataStream);
},
onError: (err) =>
err instanceof Error ? err.message : 'Agent stream failed on server.',
});
} catch (err) { const message = err instanceof Error ? err.message : 'Unexpected server error'; return NextResponse.json({ error: message }, { status: 500 }); } } `
Step 3: The Complete Integrated DevPulseStreamChat.tsx Client Component
Now let us assemble the final src/components/DevPulseStreamChat.tsx leaf component. Look at how every piece we built in earlier posts snaps into place:
useChatwithinitialMessagesandonFinish: () => router.refresh(): Hydrates saved history from our Server Component on load and refreshes the server-renderedSessionSidebarwhenever a turn finishes.deriveAgentThinkingState+AgentThinkingTimeline: Displays the zero-CLS collapsible thinking timeline across all 4 latency stages.TypeSafeToolRenderer+approveAndPersistTicket: Renders strongly typed GitHub and Docs cards alongside our database-backed Level 2 ticket approval action.RateLimitCooldownBanner: Catches HTTP429responses frommiddleware.tsand locks the Retry button with a live countdown.
`tsx // src/components/DevPulseStreamChat.tsx 'use client';
import { useState, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { useChat, type Message } from 'ai/react'; import { useSmartAutoScroll } from '@/hooks/useSmartAutoScroll'; import { deriveAgentThinkingState } from '@/lib/agent/ui-state'; import { isToolInvocationFor } from '@/lib/agent/type-contracts'; import { approveAndPersistTicket } from '@/app/actions/approve-ticket'; import { type MessageTelemetryAnnotation } from '@/app/api/chat/route'; import AgentThinkingTimeline from './AgentThinkingTimeline'; import TypeSafeToolRenderer from './TypeSafeToolRenderer'; import RateLimitCooldownBanner from './RateLimitCooldownBanner';
interface DevPulseStreamChatProps { sessionKey: string; initialMessages: Message[]; }
export default function DevPulseStreamChat({ sessionKey, initialMessages, }: DevPulseStreamChatProps) { const router = useRouter(); const [modelId, setModelId] = useState<'claude-3-5-sonnet-latest' | 'gpt-4o'>( 'claude-3-5-sonnet-latest' ); const [approvedTickets, setApprovedTickets] = useState<Record<string, string>>({}); const [isApproving, startApprovalTransition] = useTransition();
const { messages, input, handleInputChange, handleSubmit, isLoading, stop, reload, error, } = useChat({ api: '/api/chat', id: sessionKey, initialMessages, onFinish: () => { router.refresh(); }, });
const { containerRef, handleScroll, shouldAutoScroll, scrollToBottom } = useSmartAutoScroll(messages);
const thinkingState = deriveAgentThinkingState(messages, isLoading);
function onFormSubmit(e: React.FormEvent) { e.preventDefault(); if (!input.trim() || isLoading) return;
handleSubmit(e, {
body: {
sessionId: sessionKey,
modelId,
},
});
}
function handleTicketApproval( toolCallId: string, ticketPreview: unknown ) { startApprovalTransition(async () => { const res = await approveAndPersistTicket({ sessionKey, toolCallId, ticket: ticketPreview, }); if (res.ok && res.ticketId) { setApprovedTickets((prev) => ({ ...prev, [toolCallId]: res.ticketId! })); } }); }
return ( DevPulse Issue Triage & Docs Agent Session: {sessionKey} • Max Steps: 5
<select
value={modelId}
onChange={(e) =>
setModelId(e.target.value as 'claude-3-5-sonnet-latest' | 'gpt-4o')
}
disabled={isLoading}
className='bg-slate-950 border border-slate-700 rounded-lg px-3 py-1.5 text-xs text-slate-200 disabled:opacity-50'
>
<option value='claude-3-5-sonnet-latest'>Claude 3.5 Sonnet</option>
<option value='gpt-4o'>OpenAI GPT-4o</option>
</select>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
className='relative flex-1 overflow-y-auto space-y-4 p-4 bg-slate-950 rounded-lg border border-slate-800/80'
>
{messages.map((m, idx) => {
const isLastMessage = idx === messages.length - 1;
const telemetry = (m.annotations?.[0] as unknown) as
| MessageTelemetryAnnotation
| undefined;
return (
<div
key={m.id}
className={`p-4 rounded-xl text-sm space-y-3 ${
m.role === 'user'
? 'bg-cyan-950/40 border border-cyan-800/50 ml-10'
: 'bg-slate-900 border border-slate-800 mr-10'
}`}
>
<div className='flex items-center justify-between text-[10px] uppercase tracking-wider text-slate-400'>
<span>{m.role === 'user' ? 'Developer' : 'DevPulse Agent'}</span>
{telemetry && (
<span className='text-cyan-400 font-mono lowercase'>
{telemetry.modelUsed} • {telemetry.stepCount} step(s) •{' '}
{telemetry.promptTokens} in / {telemetry.completionTokens} out •{' '}
{telemetry.latencyMs}ms
</span>
)}
</div>
{m.role === 'assistant' && isLastMessage && (
<AgentThinkingTimeline
thinkingState={thinkingState}
toolInvocations={m.toolInvocations}
/>
)}
{m.toolInvocations && m.toolInvocations.length > 0 && (
<div className='space-y-2'>
{m.toolInvocations.map((inv) => {
if (isToolInvocationFor(inv, 'draftIssueTicket') && inv.state === 'result' && inv.result.ok) {
const preview = inv.result.data.ticketPreview;
const savedId = approvedTickets[inv.toolCallId];
return (
<div
key={inv.toolCallId}
className='p-3 rounded-lg bg-slate-950 border border-amber-800/70 text-xs space-y-2'
>
<div className='flex justify-between font-mono text-amber-300'>
<span>Ticket Draft: {preview.title}</span>
<span className='uppercase'>{preview.severity}</span>
</div>
{savedId ? (
<div className='text-emerald-400 font-mono'>
Persisted to Supabase (Ticket UUID: {savedId.slice(0, 8)})
</div>
) : (
<button
type='button'
disabled={isApproving}
onClick={() => handleTicketApproval(inv.toolCallId, preview)}
className='px-3 py-1 rounded bg-emerald-600 hover:bg-emerald-500 text-white font-medium'
>
{isApproving ? 'Saving to DB...' : 'Approve & Save Ticket'}
</button>
)}
</div>
);
}
return (
<TypeSafeToolRenderer
key={inv.toolCallId}
invocation={inv}
/>
);
})}
</div>
)}
{m.content && (
<div className='whitespace-pre-wrap leading-relaxed'>
{m.content}
</div>
)}
</div>
);
})}
{!shouldAutoScroll && isLoading && (
<button
type='button'
onClick={scrollToBottom}
className='sticky bottom-2 left-1/2 -translate-x-1/2 px-3 py-1 rounded-full bg-cyan-700 text-xs font-medium shadow-lg'
>
Resume Auto-Scroll
</button>
)}
</div>
<RateLimitCooldownBanner
error={error}
onRetry={() => reload({ body: { sessionId: sessionKey, modelId } })}
/>
<form onSubmit={onFormSubmit} className='flex gap-2'>
<input
value={input}
onChange={handleInputChange}
placeholder='Ask DevPulse to check docs, inspect a GitHub issue, or draft a bug ticket...'
className='flex-1 px-3.5 py-2.5 rounded-lg bg-slate-950 border border-slate-700 text-sm focus:outline-none focus:border-cyan-500'
/>
{isLoading ? (
<button
type='button'
onClick={stop}
className='px-4 py-2.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-sm font-semibold'
>
Stop
</button>
) : (
<button
type='submit'
disabled={!input.trim()}
className='px-4 py-2.5 rounded-lg bg-cyan-600 hover:bg-cyan-500 disabled:opacity-50 text-sm font-semibold'
>
Send
</button>
)}
</form>
</div>
); } `
End-to-End Smoke Test: Verifying All 4 Phases in One Prompt
Before declaring any agent project complete, run this single compound prompt in your local browser to verify that every subsystem from Posts 13 to 25 works in harmony:
'Check our internal rate-limit docs for 429 burst rules, inspect live GitHub issue #402 in vercel/ai, and draft a high-severity ticket for api-gateway if token refresh bursts are still causing timeouts.'
Watch the exact sequence that unfolds across your stack:
- Edge Guard (
middleware.ts): Verifies your IP against the Upstash Redis sliding window in ~12ms and attachesX-RateLimit-Remaining: 4. - Thinking Timeline (
AgentThinkingTimeline.tsx): Immediately mounts in the'Routing'state (0.1s..0.4s), then transitions through Step 1 (searchKnowledgeBase), Step 2 (inspectLiveGitHubIssue), and Step 3 (draftIssueTicket). - Type-Safe Cards (
TypeSafeToolRenderer.tsx): Renders the retrieved documentation score, the live GitHub issue card (vercel/ai #402), and the interactive amber Level 2 ticket draft card. - Synthesis & Persistence (
onFinish): Streams the final markdown explanation, collapses the thinking timeline into'Thought for 4.1s across 3 tool step(s)', saves the entire turn andJSONBtool array into Supabase, and triggersrouter.refresh()so your new session appears inSessionSidebar.tsx.
Zahid's Engineering Perspective: How Session Switching Failed Silently Until I Keyed the Chat Leaf
When I was assembling the final component trees in my earlier projects like GitHub Issues Tracker and Movie Explorer, switching between selected items worked automatically because clicking a card updated a state variable inside the same client component. When I put all the pieces of DevPulse together with our Server Component SessionSidebar and Client Component DevPulseStreamChat, I hit a subtle integration bug that caught me completely off guard.
I clicked on Session A in the sidebar, saw Session A's messages load, and then clicked + New in the sidebar to change the URL to /?session=devpulse-1762790000. On the server, page.tsx ran again and passed the new empty initialMessages array down to DevPulseStreamChat. Yet on my screen, Session A's old messages and approvedTickets map remained stuck inside the chat box!
Why? Because DevPulseStreamChat stayed mounted in the exact same position in the React component tree across the route transition, so React reused the existing component instance and its local useState hooks. Passing key={activeSessionKey} directly on the DevPulseStreamChat element inside src/app/page.tsx forced React to cleanly unmount the old session's client instance and mount a fresh instance whenever the URL ?session= parameter changed. That single attribute eliminated stale state carryover across the entire workspace.
Common Mistakes When Integrating a Full-Stack AI Agent
As you review your completed Next.js agent repository, verify that you have not left any of these four integration bugs behind:
- Omitting
key={activeSessionKey}on the Chat Client Component: When a user navigates between/?session=abcand/?session=xyz, React reuses the mounted chat component instance by default. Always passkey={activeSessionKey}inpage.tsxso local state hooks (useSmartAutoScroll,approvedTickets, anduseChatbuffers) reset cleanly on session switch. - Leaving Human-in-the-Loop Approvals as Ephemeral UI State: If your Level 2 tool confirmation button only calls
useStatein the browser without invoking a backend Server Action (approveAndPersistTicket), the user's approval never reaches your database. Always back human confirmation buttons with an idempotent server mutation (onConflict: 'tool_call_id'). - Forgetting
router.refresh()After Creating a New Session Turn: When a user opens a fresh/?session=devpulse-...URL, that session does not exist in theagent_sessionstable until their first turn completes insideonFinish. Callingrouter.refresh()inside the clientuseChat({ onFinish })callback re-runsSessionSidebaron the server so the newly persisted session immediately appears in the left sidebar. - Hardcoding Different
maxStepsor Model Lists Across Files: As your project grows to 15+ files, hardcoding'claude-3-5-sonnet-latest'in one file andmaxSteps: 3in another leads to silent configuration drift. Always importdefaultDevPulseConfigand Zod enums from your single source of truth (blueprint.tsandtype-contracts.ts).
Frequently Asked Questions
Is this Next.js AI agent architecture ready to deploy to production right now?
Yes. With Edge Middleware rate limiting (@upstash/ratelimit), server-only API key isolation, 5-second AbortSignal.timeout() guards on external tools, end-to-end TypeScript contracts, and Supabase JSONB persistence, this exact architecture is ready for production deployment—which we will walk through step by step when we deploy to Vercel and Docker VPS in Section C.
How do I add a fifth or sixth tool to DevPulse later without rewriting the UI?
Thanks to the modular architecture we built across Posts 12, 19, and 21, adding a new tool is a 3-step process: 1) define the Zod input schema and output DTO in type-contracts.ts, 2) bind the server executor in vercel-tools.ts, and 3) add an if (isToolInvocationFor(inv, 'yourNewTool')) branch in TypeSafeToolRenderer.tsx.
What comes next after completing this Next.js AI Agent implementation module?
Now that you have mastered building a full-stack TypeScript and Next.js agent (Section B, Posts 13–26), Section C (Posts 27–36) expands our engineering toolkit beyond Next.js: we will build a Python + FastAPI agent microservice, wire up pgvector semantic search in Supabase, containerize self-hosted agents with Docker on a VPS, and automate background agent workflows with webhooks, cron jobs, and Redis.
Need a High-Performance Web App or Custom AI Solution?
I help founders, businesses, and engineering teams build lightning-fast web applications, resilient backend architectures, and intelligent AI workflows. Have an idea in mind? Let’s bring it to life.
Found this article helpful?
Give some claps to support more in-depth engineering logs!
Zahid Hasan Tonmoy
Author & Developer
MERN Full Stack Developer & AI Agent Developer based in Dhaka, Bangladesh. Writing about web development, React, PostgreSQL and my learning journey.