Server-Side Storage & SSR-safe Consent

Read the consent cookie on the server, seed initial state, then let the cookieAdapter take over on the client. Patterns for Next.js, Remix, Astro, and SvelteKit.

Overview

The toolkit's default persistence layer is localStorageAdapter. That works fine in a SPA but on the server window and localStorageare undefined. The consent state is therefore rendered as "unset" on the server, then revealed-or-hidden during hydration when the adapter finally runs in the browser — a visible flash and, with strict hydration, a console warning.

cookieAdapter works in the browser too (it uses document.cookie), but the same cookie is visible to the framework's request API on the server. That gives you a single source of truth that both sides can read. The pattern:

  1. Configure the toolkit on the client with cookieAdapter so writes land in a cookie.
  2. In your server-rendering layer, read that same cookie from the request headers.
  3. Parse the JSON payload and pass it as initial state to the client component that hosts the banner.
  4. The banner now hydrates in the correct state (visible only when consent is missing or stale). The cookieAdapter handles every subsequent read and write on the client.

You do not need a new server-side adapter — the existing cookieAdapter at@tantainnovative/ndpr-toolkit/adapters is the persistence layer in both directions. The server just needs to read the same cookie name.

Next.js App Router (RSC)

Read the cookie in app/layout.tsx (a Server Component) and forward the parsed value to a client wrapper:

// app/layout.tsx
import { cookies } from 'next/headers';
import { parseConsentCookie } from '@/lib/parse-consent-cookie';
import { ConsentRoot } from './ConsentRoot';
import '@tantainnovative/ndpr-toolkit/styles';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const cookieStore = await cookies();
  const initialConsent = parseConsentCookie(cookieStore.get('ndpr-consent')?.value);

  return (
    <html lang="en">
      <body>
        <ConsentRoot initialConsent={initialConsent}>{children}</ConsentRoot>
      </body>
    </html>
  );
}
// app/ConsentRoot.tsx
'use client';

import { useMemo } from 'react';
import { ConsentBanner } from '@tantainnovative/ndpr-toolkit/consent';
import { cookieAdapter } from '@tantainnovative/ndpr-toolkit/adapters';
import type { ConsentSettings, ConsentOption } from '@tantainnovative/ndpr-toolkit';

const OPTIONS: ConsentOption[] = [
  { id: 'essential', label: 'Essential', description: 'Required.', required: true, purpose: 'Site operation' },
  { id: 'analytics', label: 'Analytics', description: 'Usage stats.', required: false, purpose: 'Analytics' },
];

export function ConsentRoot({
  initialConsent,
  children,
}: { initialConsent: ConsentSettings | null; children: React.ReactNode }) {
  const adapter = useMemo(() => cookieAdapter<ConsentSettings>('ndpr-consent'), []);
  const hasConsent = initialConsent?.hasInteracted === true;

  return (
    <>
      {children}
      <ConsentBanner
        options={OPTIONS}
        manageStorage={false}
        show={!hasConsent}
        onSave={(settings) => adapter.save(settings)}
      />
    </>
  );
}

manageStorage={false} tells the banner not to talk to localStorage directly — the cookie adapter owns persistence end to end. show is derived from server-parsed state, so the banner is rendered in its final visibility on the first paint.

Remix

Parse the cookie inside a loader and read it via useLoaderData in the component:

// app/root.tsx
import { json, type LoaderFunctionArgs } from '@remix-run/node';
import { Outlet, useLoaderData } from '@remix-run/react';
import { parseConsentCookie } from '~/lib/parse-consent-cookie';
import { ConsentRoot } from '~/components/ConsentRoot';
import '@tantainnovative/ndpr-toolkit/styles';

export async function loader({ request }: LoaderFunctionArgs) {
  const header = request.headers.get('cookie') ?? '';
  const match = header.split(';').map((c) => c.trim()).find((c) => c.startsWith('ndpr-consent='));
  const raw = match?.slice('ndpr-consent='.length);
  return json({ initialConsent: parseConsentCookie(raw) });
}

export default function App() {
  const { initialConsent } = useLoaderData<typeof loader>();
  return (
    <html lang="en">
      <body>
        <ConsentRoot initialConsent={initialConsent}>
          <Outlet />
        </ConsentRoot>
      </body>
    </html>
  );
}

ConsentRoot here is the same client wrapper as the Next.js example — it imports ConsentBanner and cookieAdapter, decides show from the prop, and writes through the adapter on save. Remix has no "use client" boundary; every component runs in both environments, so no separate file is strictly required.

Astro

Astro pages run on the server by default. Use Astro.cookies.get in the frontmatter and pass the parsed value into a React island:

---
// src/pages/index.astro
import { parseConsentCookie } from '../lib/parse-consent-cookie';
import { ConsentRoot } from '../components/ConsentRoot';
import '@tantainnovative/ndpr-toolkit/styles';

const initialConsent = parseConsentCookie(Astro.cookies.get('ndpr-consent')?.value);
---
<html lang="en">
  <body>
    <main>
      <h1>Welcome</h1>
    </main>
    <ConsentRoot client:load initialConsent={initialConsent} />
  </body>
</html>

client:load hydrates the React island immediately. The prop passed across the bridge is plain JSON so it survives serialisation; the island itself is the same ConsentRoot as before.

SvelteKit

The toolkit's UI is React-only, so this applies if you mount the React banner via a wrapper such as svelte-react or run it inside a Svelte component shell. The server side of the pattern is unchanged:

// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types';
import { parseConsentCookie } from '$lib/parse-consent-cookie';

export const load: LayoutServerLoad = ({ cookies }) => ({
  initialConsent: parseConsentCookie(cookies.get('ndpr-consent')),
});

The value is then available as data.initialConsent in +layout.svelte and can be forwarded to the React island.

Caveats

Cookie size limits

Browsers cap a single cookie at roughly 4 KB. The default ConsentSettings payload is well under 1 KB, but if you push large dataCategories arrays into each option you can outgrow that. If the payload starts approaching the limit, store only the minimal decision map in the cookie and keep the full settings server-side keyed by a session cookie.

Cookie security

The toolkit's cookieAdapter writes with SameSite=Lax and Secure by default. Both are appropriate for a consent cookie. Do not set HttpOnly — the client needs to read it to render the banner and to write updates. If your threat model includes XSS-based consent tampering, store the canonical consent record server-side and treat the cookie as a hint.

SSR/CSR mismatch

The bridge eliminates the visible flash but only if the server-parsed cookie matches what the client would compute. Two ways that can drift:

  • The cookie name on the server differs from the key passed to cookieAdapter on the client. Keep both at ndpr-consent or pull the name from a shared constant.
  • The version pinned on the banner via <ConsentBanner version="..."> changes. The banner treats a stale version as "needs re-consent", so derive show from parsed?.version === currentVersion, not from hasInteracted alone.

Edge cases per framework

The pattern works in every server-rendered React framework that exposes a request-cookie API. It does not apply to static export (next export, Astro's default output: 'static') because there is no request and therefore no cookie to read at build time. Use SSR (output: 'server') or fall back to the default localStorage adapter and accept the hydration flash.

See also