
In our previous tutorial on persisting your AI agent's full conversation history in Supabase, we fetched saved messages inside src/app/page.tsx on the server and passed them down as initialMessages into our interactive React chat component. That one handoff between an async server page and a reactive browser component worked smoothly, yet as an AI agent application grows to include session sidebars, documentation viewers, live tool approval cards, and token telemetry dashboards, developers often get tripped up by a fundamental architectural question: what belongs in a React Server Component (RSC), and what must live in a Client Component ('use client')?
In a Next.js App Router AI agent architecture, Server Components should own initial database hydration (loadSessionHistory), sidebar session lists, environment-secret tool registries, and static documentation previews, while Client Components should be pushed down to the leaf nodes that handle high-frequency token streaming (useChat), AbortController stream cancellation, live thinking timers, and interactive Level 2 tool confirmation buttons. Separating these responsibilities via the React children slot pattern prevents your server-only LLM modules from leaking into the browser bundle and eliminates client-side useEffect data-fetching waterfalls.
This post kicks off Phase 4 (Posts 25–26), the final milestone of our Next.js implementation module. Today we establish strict architectural boundaries between our server and client trees so that in Post 26 we can assemble every piece of DevPulse into a unified, production-ready application.
Why the Server/Client Boundary Matters Twice as Much for AI Agents
In a simple marketing website, putting 'use client' at the top of a page component merely increases the JavaScript bundle size by a few kilobytes. In an AI agent application, drawing the 'use client' boundary in the wrong place causes three immediate engineering failures:
- Build-Time
server-onlyCrashes and Secret Exposure: Your agent backend relies on modules that touchprocess.env.OPENAI_API_KEY,process.env.ANTHROPIC_API_KEY, andprocess.env.SUPABASE_SERVICE_ROLE_KEY. If a component marked with'use client'accidentally imports a helper from a file that also imports your database client or LLM SDK, Next.js either fails the build (thanks toimport 'server-only') or attempts to bundle Node.js server primitives into browser JavaScript. - Client-Side Fetch Waterfalls on Session Switch: If your session history sidebar and chat history loader are built as Client Components that fetch data inside
useEffect, the user first downloads the HTML shell, then boots React, then fires afetch('/api/sessions')request, and then fires a secondfetch('/api/messages')request—staring at two consecutive loading skeletons on every page load. - Unnecessary Re-Renders During 80-Token/Sec Streaming: While
useChatis streaming tokens at 80 chunks per second, the component holding themessagesstate re-renders on every frame. If your entire page layout, navigation bar, and session list sit inside that same monolithic Client Component, React has to diff the entire page tree dozens of times a second.
The Architectural Responsibility Matrix for AI Agents
Before writing code, let us map every subsystem of our DevPulse agent to its rightful execution environment:
- Workspace Page Shell & Route Params (
page.tsx) -> Server Component: Reads the URL?session=parameter and orchestrates parallel server-side database queries (Promise.all) before sending HTML. - Session History Sidebar (
SessionSidebar.tsx) -> Server Component: Queries theagent_sessionstable in Supabase directly on the server and renders<Link>navigation items with zero client JavaScript. - Agent Config & System Prompt Inspector -> Server Component: Reads
defaultDevPulseConfigand active tool descriptions on the server without shipping raw system prompts to the client bundle. - Live Streaming Viewport (
DevPulseStreamChat.tsx) -> Client Component ('use client'): HoldsuseChat,useSmartAutoScroll, the input form, and theStop(AbortController) button. - Thinking Timeline & Tool Approval Cards -> Client Component (
'use client'): Runs the100mssetIntervaltimer, accordion toggle (isExpanded), and interactive Level 2Approve & Save Ticketbutton state.

Here is how data and component slots flow across the Next.js Server/Client boundary in our DevPulse workspace:
flowchart TD
subgraph Server [Next.js Server Runtime - Zero Browser JS]
Page[Workspace Page RSC - page.tsx] -->|Parallel Promise.all| DB[(Supabase PostgreSQL)]
Page --> SidebarRSC[SessionSidebar RSC - Server Rendered List]
Page --> HeaderRSC[AgentStatusHeader RSC - Static Model & Tool Specs]
API[POST /api/chat Route Handler] --> Tools[Server Tool Registry + LLM SDKs]
end
subgraph Boundary [Serialization Boundary - Plain JSON Props & ReactNodes]
Props[initialMessages + sessionKey + sidebarSlot + headerSlot]
end
subgraph Client [Browser Runtime - Leaf Client Components]
Shell[AgentWorkspaceShell - use client Drawer Toggle]
ChatLeaf[DevPulseStreamChat - use client useChat Hook]
TimelineLeaf[AgentThinkingTimeline - use client 100ms Timer]
ToolLeaf[TypeSafeToolRenderer - use client Level 2 Approval]
end
Page --> Props
SidebarRSC --> Props
HeaderRSC --> Props
Props --> Shell
Shell --> ChatLeaf
ChatLeaf --> TimelineLeaf
ChatLeaf --> ToolLeaf
ChatLeaf <-->|SSE Data Stream| APIArchitecture Flow Summary: The top-level page.tsx Server Component runs parallel Supabase queries on the server to pre-render SessionSidebar and AgentStatusHeader as pure Server Components. It passes those server-rendered elements as ReactNode slots alongside plain JSON initialMessages across the serialization boundary into lightweight leaf Client Components (AgentWorkspaceShell and DevPulseStreamChat), which handle live token streaming and interactive tool approvals against /api/chat.
Step 1: Defining the Serializable Boundary Contract (boundary-types.ts)
Every prop passed from a Server Component to a Client Component must cross the React Flight Serialization Boundary. That means you cannot pass functions, database connection instances, Zod schema objects (which contain internal validation methods), or unserialized class instances as props.
Let us create src/lib/agent/boundary-types.ts, which defines the exact plain-JSON Data Transfer Objects (DTOs) allowed to cross from our Server Components into our Client Components, along with a server-side mapper that strips Date objects into ISO strings:
// src/lib/agent/boundary-types.ts
import { type Message } from 'ai';
import { type AutonomyLevel } from './blueprint';
export interface SerializableSessionItem {
id: string;
externalSessionKey: string;
title: string;
activeModel: 'gpt-4o' | 'claude-3-5-sonnet-latest';
updatedAtIso: string;
}
export interface SerializableAgentBadgeInfo {
autonomyLevel: AutonomyLevel;
maxSteps: number;
registeredToolNames: string[];
}
export interface AgentWorkspaceClientProps {
activeSessionKey: string;
initialMessages: Message[];
agentBadge: SerializableAgentBadgeInfo;
}
Now let us add a query helper loadRecentSessions() to src/lib/agent/history-store.ts (from Post 24) that fetches the 15 most recent debugging sessions from Supabase and returns SerializableSessionItem[]:
// Add to src/lib/agent/history-store.ts
import { type SerializableSessionItem } from './boundary-types';
interface AgentSessionRow {
id: string;
external_session_key: string;
title: string;
active_model: 'gpt-4o' | 'claude-3-5-sonnet-latest';
updated_at: string;
}
export async function loadRecentSessions(
limit = 15
): Promise<SerializableSessionItem[]> {
const supabase = getSupabaseAdmin();
const { data: rows, error } = await supabase
.from('agent_sessions')
.select('id, external_session_key, title, active_model, updated_at')
.order('updated_at', { ascending: false })
.limit(limit);
if (error || !rows) {
return [];
}
return (rows as AgentSessionRow[]).map((row) => ({
id: row.id,
externalSessionKey: row.external_session_key,
title: row.title,
activeModel: row.active_model,
updatedAtIso: new Date(row.updated_at).toISOString(),
}));
}
Step 2: Building Pure Server Components for the Sidebar and Header
Why make the session list sidebar a Server Component? Because rendering a list of session links and formatting timestamps does not require useState or useEffect. Keeping SessionSidebar.tsx as a Server Component means zero bytes of sidebar rendering code are shipped to the user's browser.
Let us create src/components/SessionSidebar.tsx (notice there is no 'use client' directive):
// src/components/SessionSidebar.tsx
import Link from 'next/link';
import { type SerializableSessionItem } from '@/lib/agent/boundary-types';
interface SessionSidebarProps {
sessions: SerializableSessionItem[];
activeSessionKey: string;
}
export default function SessionSidebar({
sessions,
activeSessionKey,
}: SessionSidebarProps) {
return (
<aside className='w-64 shrink-0 bg-slate-950 border-r border-slate-800 p-4 flex flex-col justify-between'>
<div className='space-y-4'>
<div className='flex items-center justify-between'>
<span className='text-xs font-bold uppercase tracking-wider text-slate-400'>
Triage Sessions
</span>
<Link
href={`/?session=devpulse-${Date.now()}`}
className='px-2.5 py-1 rounded bg-cyan-600/20 border border-cyan-500/40 text-cyan-300 text-xs font-medium hover:bg-cyan-600/30'
>
+ New
</Link>
</div>
<nav className='space-y-1.5'>
{sessions.length === 0 && (
<p className='text-xs text-slate-500 py-4'>
No saved sessions yet. Start a query to persist history.
</p>
)}
{sessions.map((item) => {
const isActive = item.externalSessionKey === activeSessionKey;
return (
<Link
key={item.id}
href={`/?session=${encodeURIComponent(item.externalSessionKey)}`}
className={`block p-2.5 rounded-lg text-xs transition-colors ${
isActive
? 'bg-cyan-950/60 border border-cyan-700/60 text-cyan-200'
: 'bg-slate-900/50 border border-slate-800/60 text-slate-300 hover:bg-slate-900'
}`}
>
<div className='font-medium truncate'>{item.title}</div>
<div className='text-[10px] text-slate-500 font-mono mt-1 flex justify-between'>
<span>{item.activeModel}</span>
<span>{item.updatedAtIso.slice(0, 10)}</span>
</div>
</Link>
);
})}
</nav>
</div>
<div className='pt-4 border-t border-slate-900 text-[11px] text-slate-500 font-mono'>
RSC Hydrated • Zero Client JS
</div>
</aside>
);
}
Step 3: Using the Slot Pattern to Wrap Server Components Inside a Client Shell
Here is a classic Next.js trap: suppose you want a button on mobile screens that toggles the visibility of SessionSidebar. If you import SessionSidebar directly inside a 'use client' layout wrapper, SessionSidebar is silently converted into a Client Component! Every component imported directly by a 'use client' file becomes part of the client bundle.
Instead, you use the React Composition / Slot Pattern: pass the server-rendered <SessionSidebar /> as a sidebarSlot: React.ReactNode prop into the Client Component wrapper. That way, the Client Component controls the mobile drawer isOpen state, while SessionSidebar remains a 100% pure Server Component!
Let us create src/components/AgentWorkspaceShell.tsx:
// src/components/AgentWorkspaceShell.tsx
'use client';
import { useState, type ReactNode } from 'react';
interface AgentWorkspaceShellProps {
sidebarSlot: ReactNode;
headerSlot: ReactNode;
children: ReactNode;
}
export default function AgentWorkspaceShell({
sidebarSlot,
headerSlot,
children,
}: AgentWorkspaceShellProps) {
const [isMobileSidebarOpen, setIsMobileSidebarOpen] = useState(false);
return (
<div className='min-h-screen bg-slate-950 text-slate-100 flex flex-col'>
<header className='border-b border-slate-800 px-4 py-3 flex items-center justify-between bg-slate-900/60'>
<div className='flex items-center gap-3'>
<button
type='button'
onClick={() => setIsMobileSidebarOpen((prev) => !prev)}
className='md:hidden px-2.5 py-1 rounded bg-slate-800 text-xs text-slate-200'
>
{isMobileSidebarOpen ? 'Close Menu' : 'Sessions ☰'}
</button>
{headerSlot}
</div>
</header>
<div className='flex-1 flex overflow-hidden'>
<div
className={`${
isMobileSidebarOpen ? 'block' : 'hidden'
} md:block shrink-0`}
>
{sidebarSlot}
</div>
<main className='flex-1 overflow-y-auto p-4 md:p-6'>{children}</main>
</div>
</div>
);
}
Step 4: Assembling the Server Component Entrypoint (src/app/page.tsx)
Now look at how src/app/page.tsx brings everything together on the server. It runs loadSessionHistory and loadRecentSessions in parallel via Promise.all (eliminating sequential database waterfalls), reads the server-only defaultDevPulseConfig, and injects the Server Components into AgentWorkspaceShell alongside our leaf DevPulseStreamChat Client Component:
// src/app/page.tsx
import { defaultDevPulseConfig } from '@/lib/agent/blueprint';
import {
loadSessionHistory,
loadRecentSessions,
} from '@/lib/agent/history-store';
import { devPulseTools } from '@/lib/agent/vercel-tools';
import SessionSidebar from '@/components/SessionSidebar';
import AgentWorkspaceShell from '@/components/AgentWorkspaceShell';
import DevPulseStreamChat from '@/components/DevPulseStreamChat';
interface PageProps {
searchParams: Promise<{ session?: string }>;
}
export default async function DevPulseWorkspacePage({ searchParams }: PageProps) {
const params = await searchParams;
const activeSessionKey = params.session ?? 'devpulse-prod-session-01';
const [initialMessages, recentSessions] = await Promise.all([
loadSessionHistory(activeSessionKey, 40),
loadRecentSessions(15),
]);
const registeredToolNames = Object.keys(devPulseTools);
const headerSummary = (
<div className='flex items-center gap-3 text-xs'>
<span className='font-semibold text-cyan-400'>DevPulse Workspace</span>
<span className='px-2 py-0.5 rounded bg-slate-800 text-slate-300 font-mono text-[11px]'>
{defaultDevPulseConfig.autonomyLevel}
</span>
<span className='hidden sm:inline text-slate-400 font-mono text-[11px]'>
Tools ({registeredToolNames.length}): {registeredToolNames.join(', ')}
</span>
</div>
);
return (
<AgentWorkspaceShell
headerSlot={headerSummary}
sidebarSlot={
<SessionSidebar
sessions={recentSessions}
activeSessionKey={activeSessionKey}
/>
}
>
<DevPulseStreamChat
sessionKey={activeSessionKey}
initialMessages={initialMessages}
/>
</AgentWorkspaceShell>
);
}
Notice how Object.keys(devPulseTools) is evaluated safely inside page.tsx on the server! Our client components never import vercel-tools.ts, yet the user sees the live list of active server tools rendered inside headerSummary on the very first paint.

A Real Architectural Lesson: How One Top-Level 'use client' Broke My Build and Bloated the Bundle
When I was structuring component trees in my earlier React projects like Movie Explorer, every file lived in the client bundle because standard SPA React has no concept of Server Components. When I started building full-stack Next.js App Router applications and early DevPulse prototypes, I carried over an old SPA habit: I wanted to add a quick dark/light theme toggle state (useState) right inside my top-level page.tsx file, so I typed 'use client' on Line 1 of src/app/page.tsx.
Everything fell apart immediately in two ways.
First, because page.tsx was also importing a helper from my agent configuration module (which transitively imported history-store.ts with import 'server-only'), the Next.js compiler immediately threw a fatal build error: 'You're importing a component that needs server-only. That only works in a Server Component.' Second, when I temporarily removed the server import just to inspect the bundle, I realized that marking the root page as 'use client' forced every child component—including static markdown documentation panels and session lists—into the client JavaScript bundle, AND forced me to move my fast server-side loadSessionHistory() call into a slow client-side useEffect fetch that flashed a blank screen for 600ms on every refresh!
Refactoring page.tsx back into an async Server Component and pushing 'use client' down into leaf components (AgentWorkspaceShell for the mobile drawer toggle and DevPulseStreamChat for the useChat stream) solved both problems permanently. Now, Promise.all([loadSessionHistory(), loadRecentSessions()]) runs right next to the database on the server, and only the interactive chat leaf ships client JavaScript.
Common Mistakes When Mixing Client and Server Components in AI Agents
Before we assemble our complete end-to-end mini-agent in Post 26, make sure your Next.js architecture avoids these four boundary mistakes:
1. Importing a Server Component Directly Inside a 'use client' File
Remember that 'use client' defines a module boundary: any component you import at the top of a 'use client' file automatically becomes a Client Component. If you want an async Server Component (like SessionSidebar) to sit inside a Client Component wrapper (like AgentWorkspaceShell), never import SessionSidebar inside AgentWorkspaceShell.tsx. Instead, instantiate <SessionSidebar /> inside the parent page.tsx Server Component and pass it down as a ReactNode prop (sidebarSlot or children).
2. Passing Non-Serializable Zod Schemas or Tool Functions as Props
Never pass devPulseTools or a Zod schema object (searchDocsSchema) as a prop from page.tsx to DevPulseStreamChat. Zod schemas and tool definitions contain JavaScript methods and closures that cannot be serialized by the React Flight protocol. Only pass plain strings, numbers, booleans, arrays, and plain objects (like registeredToolNames: string[]).
3. Fetching Initial Chat History Inside a Client useEffect
Do not run useEffect(() => { fetch('/api/history').then(...) }, []) inside your chat Client Component to load past messages on page load. Doing so delays history rendering until after client JavaScript downloads and executes, causing layout shifts and empty-chat flashes. Always fetch initialMessages inside the parent async Server Component and pass them into useChat({ initialMessages }).
4. Placing 'use client' Too High in the Component Tree
If only one button inside a 200-line agent header needs an onClick handler (such as a 'Copy Session ID' button), do not mark the entire header as 'use client'. Extract just <CopySessionButton sessionKey={activeSessionKey} /> into its own tiny 15-line 'use client' leaf component and keep the surrounding header a Server Component.
Frequently Asked Questions
Can a Next.js Server Component stream LLM tokens directly without any Client Components?
While React Server Components can stream UI nodes via createStreamableUI (AI RSC), an interactive AI agent still requires a Client Component leaf for user input management, AbortController stream cancellation (stop()), threshold-gated auto-scrolling (useSmartAutoScroll), and interactive tool approval buttons. Pairing a Server Component shell with a useChat Client Component leaf gives you the best of both worlds.
Why did we use Promise.all inside page.tsx when loading initialMessages and recentSessions?
In an async Server Component, writing two consecutive await statements (const msgs = await loadSessionHistory(); const sessions = await loadRecentSessions();) creates a sequential server waterfall where Query 2 waits for Query 1 to finish. Wrapping both independent Supabase queries in Promise.all([...]) executes them concurrently, cutting server rendering time nearly in half.
How do I refresh the Server Component SessionSidebar after the Client Component finishes a new chat turn?
When a user starts a brand-new session and onFinish persists the turn to Supabase on the server, your Client Component can call router.refresh() from next/navigation inside the onFinish callback of useChat. router.refresh() tells Next.js to re-execute page.tsx and SessionSidebar on the server and update the sidebar list in place without losing a single pixel of client scroll position or chat input state.
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.