@k8ordo/state

Preferences the server renders

A preference in localStorage never reaches the server, so the server renders the default and the real value takes over after hydration. When that switch shows, as with display density, keep the preference in a cookie instead: it goes with every request, so the server renders the real value from the start.

On this page

Define a cookie state

Hand defineCookieState a key and a schema. It is written like defineLocalState, and the schema likewise has to accept its own output.

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

export const density = defineCookieState(
  'density',
  z.object({
    density: z
      .enum(['comfortable', 'compact'])
      .default('comfortable'),
  }),
);

The values are kept in one cookie named k8ordo-state.density, as the JSON of the declared fields. The definition exposes the name as cookieName. It is joined with . rather than :, because a cookie name cannot hold a :.

For the same reason, a key may hold only letters, digits and the few symbols an HTTP token allows. A key with a space, a : or a ; is refused as the module loads.

Read it on the server

Under @k8ordo/server, pages and layouts receive the request as request. Hand its request.cookies to parseCookies, and the values come back through the schema.

routes/layout.tsx
import type { LayoutProps } from '@k8ordo/router';

import { density } from '../state';
import { Shell } from './shell';

export default function Layout({
  request,
  children,
}: LayoutProps<'/'>) {
  return (
    <Shell initialCookie={density.parseCookies(request.cookies)}>
      {children}
    </Shell>
  );
}
routes/shell.tsx
'use client';

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

import { density } from '../state';

type Props = {
  initialCookie: OutputOf<typeof density.schema>;
  children: ReactNode;
};

export function Shell({ initialCookie, children }: Props) {
  const [values] = useAppState(density, { initialCookie });

  return <div data-density={values.density}>{children}</div>;
}

parseCookies takes a ReadonlyMap<string, string> of percent-decoded values, which is what request.cookies is, so it goes in as it is. A missing cookie, broken JSON or a value the schema rejects falls back to the defaults, field by field.

Pass what you read to useAppState as initialCookie. The server render and the hydration render both use it, so the default never flashes.

Where the values have to reach

initialCookie seeds only the useAppState call it is passed to.

A component rendered on the server without initialCookie shows the defaults there. So read the cookie once, high up in a layout, and pass the result down.

@k8ordo/static has no request. The server render shows the defaults, and the cookie takes over after hydration, just as localStorage does.

The cookie the browser writes

update() writes the cookie with the Cookie Store API, with these attributes, set again on every write.

  • Path=/: it goes with a request to any path on the site.
  • SameSite=Lax: it goes with the first request arriving from a link on another site. Under the API’s default Strict, the server would render the defaults on exactly that request.
  • Max-Age: 400 days, the longest a browser keeps a cookie, renewed by every write.
  • Secure: the API always sets it, so serve the page over HTTPS. Chromium and Firefox keep it on http://localhost as well, but Safari drops it there, so develop over HTTPS to see it persist in Safari.

Reads go through document.cookie, synchronously, because a render cannot wait for the API’s promise. Writes from other tabs, and cookies a server response set, arrive through the API’s change event.

Every byte rides every request, so keep it small. A cookie over 4 KB, name and value together, is refused: the update() handle rejects with the API’s TypeError, while the rendered value stays.

Write the same cookie from the server

A page only renders; it never writes the response. Cookies are written where a request is answered: under @k8ordo/server, that is guard.ts, route.ts and Server Actions, through the framework’s cookies().

Sometimes the server should write a cookie state, say for a form that changes a preference without JavaScript. Hand cookies().set the cookieName, and what cookieValue() returns as the value. cookieValue() runs the values through the schema, and fills the fields you leave out with their defaults.

actions.ts
'use server';

import { cookies } from '@k8ordo/server/runtime';

import { density } from './state';

export async function compact() {
  cookies().set(
    density.cookieName,
    density.cookieValue({ density: 'compact' }),
    { httpOnly: false, maxAge: 34_560_000 },
  );
}

Keep httpOnly: false: script cannot see an HttpOnly cookie, and the browser store would lose it. cookies() defaults to Path=/ and SameSite=Lax, so the other attributes match what the browser writes. Open tabs take the new values in through the change event.

cookieValue() returns the JSON unencoded: cookies().set percent-encodes it on the way out, and parseCookies receives it decoded again. Writing a Set-Cookie header by hand, pass the value through encodeURIComponent yourself, once.

Never a secret

A cookie the browser writes can never be HttpOnly.

Warning

Pitfall

Any script on the page can read and rewrite it, and a visitor can edit it as freely as a URL. Keep sessions, tokens and anything else that must not leak or be forged out of it. Write those with cookies() as HttpOnly, where this package never sees them.

On the server, treat what you read as input, not trusted state. That is why parseCookies passes it through the schema before you see it.

k8ordo

React libraries that use Baseline features without holding back.

© 2026 k8o — MIT License

Typeset in Noto Sans JP & M PLUS 2