The full state matrix, URL state with nuqs, per-request server state with React cache(), cookie-based server state, Zustand with RSC, and the taint API for preventing accidental secret exposure.
A-8 — State Across the Network Boundary
Who this is for: Architects wrestling with the App Router's most counterintuitive constraint — Server Components can't hold state, Client Components can't run on the server, and data flows in one direction. This module is about the patterns that resolve these tensions: URL as state, server-to-client prop threading, context placement, and the patterns that keep your component tree sane when half of it lives on the server.
The Core Constraint
In the App Router, state lives on one side of the boundary or the other. There's no "shared" state in the traditional React sense.
SERVER SIDE CLIENT SIDE
─────────────────────────────────────────────────
Database useState
External APIs useReducer
Environment variables useContext
File system URL (searchParams)
Request cookies/headers localStorage/sessionStorage
Server-only code DOM APIs
Data flows from server to client through props. It cannot flow the other way — a Client Component cannot pass state back to a Server Component at runtime (it can submit mutations through Server Actions, which re-render Server Components, but that's not state passing).
This constraint forces a specific architectural question: where does this state actually belong?
URL as the Universal State Store
The URL is the only state that's simultaneously accessible on the server and the client. A Server Component can read searchParams. A Client Component can read useSearchParams(). Both see the same value.
This makes the URL the natural home for state that affects server rendering — filters, sort order, pagination, selected tabs:
tsx// app/products/page.tsx — Server Component reads URL state export default async function ProductsPage({ searchParams, }: { searchParams: Promise<{ category?: string; sort?: string; page?: string }>; }) { const { category, sort = 'created', page = '1' } = await searchParams; const products = await db.products.findMany({ where: category ? { categoryId: category } : undefined, orderBy: { [sort]: 'desc' }, take: 20, skip: (Number(page) - 1) * 20, }); return <ProductGrid products={products} />; }
tsx// components/ProductFilters.tsx — Client Component writes URL state 'use client'; import { useRouter, useSearchParams, usePathname } from 'next/navigation'; export function ProductFilters() { const router = useRouter(); const searchParams = useSearchParams(); const pathname = usePathname(); function setFilter(key: string, value: string) { const params = new URLSearchParams(searchParams.toString()); params.set(key, value); router.push(`${pathname}?${params.toString()}`); } return ( <div> <button onClick={() => setFilter('category', 'shoes')}>Shoes</button> <button onClick={() => setFilter('sort', 'price')}>Sort by price</button> </div> ); }
The user clicks a filter → URL changes → Server Component re-renders with the new filter → fresh database query. No client-side filtering, no state synchronisation, no stale UI. The URL is the single source of truth.
The nuance with useSearchParams: Accessing useSearchParams() in a Client Component that's inside a Server Component causes the Client Component to suspend until searchParams are available. Wrap it in a <Suspense> boundary if the filter UI shouldn't block rendering.
Threading Props from Server to Client
The most common mistake with the App Router is trying to share state between a deeply-nested Server Component and a distant Client Component. The correct pattern: hoist the data fetch to the nearest Server Component ancestor, then thread it down as props.
tsx// ❌ Fetching the same data in two places // ServerComponent1 fetches user for auth check // ClientComponent5 fetches user again for display // Result: two database calls, potential inconsistency // ✅ Fetch once, thread down // app/dashboard/layout.tsx export default async function DashboardLayout({ children, }: { children: React.ReactNode; }) { const session = await auth(); // one database call const user = session?.user; return ( <div> <DashboardNav user={user} /> {/* receives user as prop */} {children} </div> ); } // components/DashboardNav.tsx (Client Component) 'use client'; export function DashboardNav({ user }: { user: User | undefined }) { // Has the user data without fetching it }
The anti-pattern is treating Server Components like you'd treat a hook — calling auth() in every Server Component that needs the session. It's wasteful. The data flows in one direction; fetch it once at the top and pass it down.
Context Across the RSC Boundary
React context doesn't cross the RSC boundary. A createContext() in a Server Component cannot be consumed by a Client Component — and vice versa.
The pattern for sharing state with many Client Components without deep prop drilling:
tsx// components/providers/UserProvider.tsx 'use client'; import { createContext, useContext } from 'react'; const UserContext = createContext<User | null>(null); export function UserProvider({ user, children, }: { user: User | null; children: React.ReactNode; }) { return ( <UserContext.Provider value={user}> {children} </UserContext.Provider> ); } export function useUser() { const user = useContext(UserContext); return user; }
tsx// app/layout.tsx — Server Component wraps a Client Component provider export default async function RootLayout({ children, }: { children: React.ReactNode; }) { const session = await auth(); return ( <html> <body> <UserProvider user={session?.user ?? null}> {children} {/* can be Server Components — they're passed as children */} </UserProvider> </body> </html> ); }
The critical point: children passed to UserProvider can still be Server Components. The Client Component wrapper doesn't "infect" its children with client-side rendering — children props passed from a Server Component to a Client Component remain Server Components. Only components imported by Client Components become Client Components.
useFormStatus and useActionState — Form-Level State
For form state that's tightly coupled to a Server Action, React provides two hooks:
useFormStatus — reads the status of the nearest <form>:
tsx'use client'; import { useFormStatus } from 'react-dom'; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? 'Saving...' : 'Save'} </button> ); }
useFormStatus must be inside the form — it reads the form's pending state from React's context. It can't be in the same component as the <form> tag. This is why it's typically in a separate SubmitButton component.
useActionState — manages the state returned by a Server Action:
tsx'use client'; import { useActionState } from 'react'; import { updateProfile } from '@/actions/users'; export function ProfileForm({ initialData }: { initialData: User }) { const [state, formAction, isPending] = useActionState(updateProfile, { success: false, errors: null, }); return ( <form action={formAction}> <input name="name" defaultValue={initialData.name} /> {state.errors?.name && <p className="error">{state.errors.name}</p>} {state.success && <p className="success">Saved!</p>} <button disabled={isPending}>Save</button> </form> ); }
useActionState is the replacement for the deprecated useFormState. The signature is identical — it's purely a rename.
Zustand and Global Client State
For state that's genuinely global to the client application — theme, notification count, shopping cart — a state management library like Zustand integrates cleanly with the App Router.
The key: initialise Zustand stores from server-fetched data, not from independent client fetches.
tsx// lib/stores/cart.ts import { create } from 'zustand'; interface CartState { items: CartItem[]; addItem: (item: CartItem) => void; removeItem: (id: string) => void; } export const useCartStore = create<CartState>((set) => ({ items: [], addItem: (item) => set((state) => ({ items: [...state.items, item] })), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id) })), }));
tsx// components/providers/CartProvider.tsx 'use client'; import { useEffect } from 'react'; import { useCartStore } from '@/lib/stores/cart'; // Server Component fetches the cart, passes it to this provider for initialisation export function CartProvider({ initialItems, children, }: { initialItems: CartItem[]; children: React.ReactNode; }) { const setItems = useCartStore(state => state.setItems); useEffect(() => { setItems(initialItems); // initialise from server data once }, []); // eslint-disable-line — intentionally run once return <>{children}</>; }
This pattern: server fetches the authoritative cart data, passes it to the client store as initial state, client store handles subsequent optimistic updates. The cart in the Zustand store is the "fast" version; after any mutation, revalidatePath ensures the next full page navigation fetches fresh data from the server.
The cookies() / headers() Boundary
A Server Component can call cookies() and headers(). A Client Component cannot — it has no access to the request. This creates a real architectural constraint: personalisation data (user preferences stored in cookies, A/B test assignments in headers) must be fetched server-side and passed to the client.
tsx// app/layout.tsx export default async function RootLayout({ children, }: { children: React.ReactNode; }) { const cookieStore = await cookies(); const theme = cookieStore.get('theme')?.value ?? 'light'; const experiment = cookieStore.get('experiment-variant')?.value; return ( <html data-theme={theme}> <body> <ThemeProvider initialTheme={theme}> <ExperimentProvider variant={experiment}> {children} </ExperimentProvider> </ThemeProvider> </body> </html> ); }
The Server Component reads the cookies and passes the values to Client Component providers as props. The Client Component providers make the values available via context without needing to re-read the cookies (which they can't do).
Where We Go From Here
A-9 moves to the edge — feature flags, geo-routing, and the architecture of global applications that serve different content to different users at the CDN layer. With A-8's understanding of how state flows through the component tree, A-9 explains how to use Middleware and edge compute to make routing decisions before the page even renders.