Module A-8·19 min read

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.

Written by Jatin Jain Saraf · Architect of SupraScan

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) 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.

// ❌ 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 & Register

Discussion

0

Join the discussion

Loading comments...

© 2026 Jatin Jain Saraf (JJS). All rights reserved.