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.
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.
text
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
tsx
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.
nuqs — Type-Safe URL State Without Hand-Rolling It
The ProductFilters example above works, but it doesn't scale past a couple of string params. The moment you need a number, a boolean, an array, or a default value, you're writing (and re-writing, in every component) boilerplate around URLSearchParams: parsing strings to numbers, guarding against null, JSON-encoding arrays, remembering to call .toString() correctly. Get any of that wrong and you get a filter that silently resets, or a page number that's NaN.
nuqs is a library built specifically for this problem — type-safe search-param state that behaves like useState, but backed by the URL instead of component memory.
tsx
What this buys you over raw useSearchParams() plus manual URLSearchParams writes:
Parsers, not string juggling.parseAsInteger, parseAsBoolean, parseAsIsoDateTime, parseAsStringEnum, parseAsArrayOf, and others handle the serialisation and parsing (and the edge cases — missing param, malformed value) for you, in both directions.
Defaults baked into the type..withDefault(1) means page is a number, never number | null, everywhere you read it.
Batched updates.useQueryStates writes multiple params in a single URL update instead of triggering a navigation per param, which matters once a filter panel controls more than one query key at a time.
The Server Component side stays exactly the same.nuqs only changes how the client reads and writes URL state; the Server Component still reads searchParams the normal Next.js way (nuqs also ships a createSearchParamsCache / server-side loader helper for keeping the server-side parsing in sync with the same parser definitions — verify the exact export name against the version you install, since this part of the library's API has moved between major versions).
Reach for raw useSearchParams() when you're reading a single, simple string param and don't need to write to it. Reach for nuqs the moment you're managing more than one param, need a non-string type, or need default-value semantics — hand-rolling that logic across a handful of filter components is exactly the kind of repetitive, easy-to-get-subtly-wrong code a small library exists to remove.
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
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.
Hoisting-and-threading is the right pattern when you control the component tree and can pass the value down explicitly. It gets awkward when several Server Components at different depths all independently need the same data and threading it through every intermediate layer would mean adding a prop that has nothing to do with those layers' own concerns. That's the case React's cache() is for.
cache() — Per-Request Deduplication Without Prop Drilling
cache(), imported from react (not from next/cache), memoizes the result of a function call for the lifetime of a single server request. Call the same cached function from five different Server Components while rendering one request, and the underlying work — a database query, a fetch — runs once; every subsequent call in that same request returns the memoized result.
ts
tsx
Both call sites ask for the same userId during the same incoming request, so the second call resolves from the in-memory cache React maintains for that request's render — it never touches the database again. This is what makes it reasonable to call a data-fetching function directly from any Server Component that needs it, instead of forcing every fetch through prop-threading from a single top-level ancestor. You get the ergonomics of "just call the function where you need the data" without paying for N duplicate queries.
This is a genuinely different mechanism from use cache elsewhere in this course, and the two are easy to conflate:
React's cache()
Next.js's use cache
Import
react
built into Next.js (directive)
Scope
One request, one render pass
Persists across requests, across users
Lifetime
Reset to empty at the start of every new request
Survives until revalidated or its tag/time-based expiry fires
What it solves
"Don't run this function twice in the same request"
"Don't re-run this expensive work on every request at all"
Mental model
Request-scoped deduplication
Cross-request, persistent data cache
Using cache() where you meant use cache (or vice versa) is a real source of confusion: cache() will not save you a database round trip on the next incoming request — it resets completely — while use cache will, but at the cost of needing explicit revalidation when the underlying data changes. Pick cache() for "multiple components in this one render need the same thing," and use cache for "this computation is expensive and safe to reuse across users/requests."
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
tsx
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
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
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
tsx
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
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).
The Taint API — Making Accidental Leaks a Crash Instead of a Bug
Everything so far in this module has been about deliberate patterns for moving state across the boundary. This section is about the opposite failure mode: a value that was never supposed to cross the boundary at all, crossing it by accident.
The scenario is depressingly common. A Server Component fetches a full user record — including a password hash, an internal notes field, whatever — because that's what the ORM returns. A teammate, months later, spreads that object into props for a Client Component to save a few lines:
tsx
Nothing here throws a type error — user really does have a name and an email that ProfileCard wants, and spreading is a normal-looking shortcut. The passwordHash field rides along silently, gets serialized into the Client Component's props, and is now sitting in the page's initial HTML/JS payload, visible to anyone who opens dev tools.
React 19 ships two APIs for turning this into a hard failure instead of a silent leak: experimental_taintObjectReference and experimental_taintUniqueValue, both imported from react. (These are still prefixed experimental_ — verify the exact export names and any required Next.js config flag against your installed React/Next versions before relying on them in production; this API has been in flux.)
ts
With the taint in place, the earlier mistake stops being a silent leak and becomes a thrown error at render time:
tsx
experimental_taintObjectReference protects the object identity — pass that exact object (or something derived from it in a way React can still trace) into a Client Component's props or into a Server Action's serialized response, and it throws. experimental_taintUniqueValue is narrower: it protects one specific value (a string, typically) wherever it shows up, even detached from the original object — so if a developer instead writes <ClientComp passwordHash={user.passwordHash} /> without spreading the whole object, the taint on that specific string still catches it.
Neither function encrypts or removes the data — the data is still sitting in server memory exactly as before. What they add is a fail-loud guardrail: a developer who tries to move a tainted value across the RSC boundary gets an exception during development (and in production) instead of a quiet leak that only shows up when someone inspects network traffic. This is worth adding at the data-access layer for anything that's genuinely sensitive — password hashes, API keys, internal-only fields — precisely because the mistake it prevents doesn't look like a mistake at the call site.
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.
Knowledge Check
Why should you avoid using useState for filter options like "sort by price" in a Next.js App Router application?
Which of the following statements about passing state from Server Components to Client Components via React context is true?
What is the correct way to initialize a global client state library like Zustand with server-fetched data in the App Router?
Two Server Components at different depths in the same request both call a function wrapped in React's cache() with the same argument. What happens, and how does this differ from Next.js's use cache?
Test your knowledge with more question sets
Sign in to access a wider variety of questions and get notified when new practice sets are added to this module.
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
'use client';import{ useQueryState, useQueryStates, parseAsInteger, parseAsString, parseAsStringEnum }from'nuqs';exportfunctionProductFilters(){// A single param, typed as a string, with a defaultconst[category, setCategory]=useQueryState('category', parseAsString.withDefault('all'));// A single param, typed as a number, with a default — no manual Number() / NaN handlingconst[page, setPage]=useQueryState('page', parseAsInteger.withDefault(1));// Multiple related params updated together, in one URL writeconst[{ sort, order }, setSortState]=useQueryStates({ sort:parseAsStringEnum(['price','created','popularity']).withDefault('created'), order:parseAsStringEnum(['asc','desc']).withDefault('desc'),});return(<div><buttononClick={()=>setCategory('shoes')}>Shoes</button><buttononClick={()=>setSortState({ sort:'price', order:'asc'})}> Sort by price, low to high
</button><buttononClick={()=>setPage(p => p +1)}>Next page</button></div>);}
// ❌ 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.tsxexportdefaultasyncfunctionDashboardLayout({ children,}:{ children:React.ReactNode;}){const session =awaitauth();// one database callconst user = session?.user;return(<div><DashboardNavuser={user}/>{/* receives user as prop */}{children}</div>);}// components/DashboardNav.tsx (Client Component)'use client';exportfunctionDashboardNav({ user }:{ user:User|undefined}){// Has the user data without fetching it}
// lib/data/user.tsimport{ cache }from'react';import{ auth }from'@/lib/auth';import{ db }from'@/lib/db';// Wrap the lookup, not the auth() call itself — cache() keys on arguments,// so this dedupes per userId within the requestexportconst getUser =cache(async(userId:string)=>{return db.users.findUnique({ where:{ id: userId }});});
// app/dashboard/layout.tsxexportdefaultasyncfunctionDashboardLayout({ children }:{ children:React.ReactNode}){const session =awaitauth();const user =awaitgetUser(session.user.id);// DB hit #1return<div><DashboardNav user={user} />{children}</div>;}// app/dashboard/settings/page.tsx — deep in the same tree, same requestexportdefaultasyncfunctionSettingsPage(){const session =awaitauth();const user =awaitgetUser(session.user.id);// same request → memoized, no DB hitreturn<SettingsFormuser={user}/>;}
// app/layout.tsx — Server Component wraps a Client Component providerexportdefaultasyncfunctionRootLayout({ children,}:{ children:React.ReactNode;}){const session =awaitauth();return(<html><body><UserProvideruser={session?.user ??null}>{children}{/* can be Server Components — they're passed as children */}</UserProvider></body></html>);}
// 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 initialisationexportfunctionCartProvider({ 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 oncereturn<>{children}</>;}
// app/profile/page.tsx (Server Component)exportdefaultasyncfunctionProfilePage(){const user =await db.users.findUnique({ where:{ id: session.user.id}});// user includes { id, name, email, passwordHash, internalNotes, ... }// 🚨 Looks harmless. Ships the password hash to the browser.return<ProfileCard{...user}/>;}
// lib/data/user.tsimport{ cache }from'react';import{ experimental_taintObjectReference, experimental_taintUniqueValue }from'react';import{ db }from'@/lib/db';exportconst getFullUser =cache(async(userId:string)=>{const user =await db.users.findUnique({ where:{ id: userId }});// Taint the whole object: if this exact reference is ever passed// to a Client Component, React throws instead of silently serializing it.experimental_taintObjectReference('Do not pass the full user record to a Client Component — pick the fields you need instead.', user
);// Taint a specific sensitive value: even if someone extracts just// this string and tries to pass it separately, React throws.experimental_taintUniqueValue('Do not pass the password hash to a Client Component.', user, user.passwordHash
);return user;});
// app/profile/page.tsxexportdefaultasyncfunctionProfilePage(){const user =awaitgetFullUser(session.user.id);// 🚨 This now throws: "Do not pass the full user record to a Client Component..."return<ProfileCard{...user}/>;// ✅ The fix: pass only the fields the Client Component actually needs// return <ProfileCard name={user.name} email={user.email} />;}