@k8ordo/state

Next.js

Use @k8ordo/state under the Next.js App Router: hand the server-read values to the client, and know what changes under Next.js.

On this page

What works as is

app/products/state.ts
import { definePageState } from '@k8ordo/state';
import * as z from 'zod';

export const listState = definePageState('product-list', {
  url: z.object({
    inStock: z.stringbool().default(false),
    page: z.coerce.number().int().min(1).default(1),
  }),
});

Definitions and useAppState work under Next.js as they are. So do entry, localStorage, sessionStorage, cookie and memory state.

Three things differ under Next.js: reading on the server, typed paths, and an update() that changes the URL.

Reading on the server

app/products/page.tsx
import Link from 'next/link';

import { Filters } from './filters';
import { listState } from './state';

export default async function Page({
  searchParams,
}: PageProps<'/products'>) {
  const url = listState.parseUrl(await searchParams);
  const products = await fetchProducts(url);
  const nextPage = listState.href('/products', {
    ...url,
    page: url.page + 1,
  });

  return (
    <>
      <Filters initialUrl={url} />
      <ProductList products={products} />
      <Link href={nextPage}>Next page</Link>
    </>
  );
}

An App Router page receives searchParams. Hand it to parseUrl to get the values read with the url schema: a missing parameter takes its default, and a value the schema rejects sends only that field back to its default.

parseUrl takes a UrlInput: a URLSearchParams, or an object of the shape Record<string, string | string[] | undefined>. Reading never throws.

href is a pure function, so it runs inside a Server Component as well.

app/products/filters.tsx
'use client';

import { useAppState } from '@k8ordo/state';
import type { OutputOf } from '@k8ordo/state';

import { listState } from './state';

type FiltersProps = { initialUrl: OutputOf<typeof listState.url> };

export function Filters({ initialUrl }: FiltersProps) {
  const [{ inStock }] = useAppState(listState, { initialUrl });

  return (
    <form action="/products" method="get">
      <label>
        <input
          defaultChecked={inStock}
          name="inStock"
          type="checkbox"
          value="true"
        />
        In stock only
      </label>
      <button type="submit">Apply</button>
    </form>
  );
}

Pass the values read to the Client Component as initialUrl. The server render and the hydration render then show the URL’s values, not the defaults.

Typed paths

types/k8ordo-state.d.ts
import type { Route } from 'next';

declare module '@k8ordo/state' {
  interface Register {
    path: Route;
  }
}

Next.js has no route table to hand over, so register Route from next under path. Every href in the application then turns a path Next.js does not know into a type error.

With typedRoutes enabled, Route is the union of the application’s paths. The return type of href keeps the path as a literal, so it passes the typed check of Next.js’s <Link> too.

Augment Register only in an application. A shared library that augments it imposes the constraint on every consumer.

update() that changes the URL

The Next.js router does not call intercept() on the navigate event, so the navigation.navigate() an update() makes to change the URL is a full page load.

Change the URL with links and GET forms. How to build a link is on Links. There is no History API fallback.

An update() that leaves the URL alone is unaffected.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2