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:
// 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} />;
}
// 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.
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.
'use client';
import { useQueryState, useQueryStates, parseAsInteger, parseAsString, parseAsStringEnum } from 'nuqs';
export function ProductFilters() {
// A single param, typed as a string, with a default
const [category, setCategory] = useQueryState('category', parseAsString.withDefault('all'));
// A single param, typed as a number, with a default — no manual Number() / NaN handling
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
// Multiple related params updated together, in one URL write
const [{ sort, order }, setSortState] = useQueryStates({
sort: parseAsStringEnum(['price', 'created', 'popularity']).withDefault('created'),
order: parseAsStringEnum(['asc', 'desc']).withDefault('desc'),
});
return (
<div>
<button onClick={() => setCategory('shoes')}>Shoes</button>
<button onClick={() => setSortState({ sort: 'price', order: 'asc' })}>
Sort by price, low to high
</button>
<button onClick={() => setPage(p => p + 1)}>Next page</button>
</div>
);
}
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)meanspageis anumber, nevernumber | null, everywhere you read it. - Batched updates.
useQueryStateswrites 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.
nuqsonly changes how the client reads and writes URL state; the Server Component still readssearchParamsthe normal Next.js way (nuqs also ships acreateSearchParamsCache/ 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.
// ❌ 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
}
Sign in to keep reading
The rest of this module is free — sign in with Google to unlock it and track your progress.
Sign in & RegisterDiscussion
0Join the discussion