Nexus DSdocs

Theme setup

Nexus Appearance derives a full runtime theme from one state object, paints it before React hydrates, and persists the user's choices. Setup has two pieces: a provider and a first-paint script.

Before you start

This page continues from Install, which leaves you with the token CSS in styles/nexus/ imported from your CSS entry point, the appearance provider copied into components/appearance/provider/, and @nexus_ds/core installed.

The Next.js layout below replaces the one Install ends with: same file, but it becomes async, reads the appearance from a cookie, and swaps DEFAULT_NEXUS_APPEARANCE for the defaultAppearance and cookie helpers below. The Vite section is a separate track — take it instead of the Next.js one.

Pick a default appearance

Customize the shipped default by changing state fields. Brand and surface tone are state fields, not factory arguments.

This is what replaces the bare DEFAULT_NEXUS_APPEARANCE that Install passes to the provider and the script:

// app/appearance-state.ts or src/appearance-state.ts
import {
  DEFAULT_NEXUS_APPEARANCE,
  type NexusAppearanceState,
} from '@nexus_ds/core';

export const defaultAppearance = {
  ...DEFAULT_NEXUS_APPEARANCE,
  brandColor: '#2563eb',
  surfaceTone: 'slate',
} satisfies NexusAppearanceState;

Surface tones are stone, neutral, zinc, slate, and gray.

Next.js App Router

Read the cookie on the server, convert its raw string value into a snapshot, put the inline script in <head>, and render the provider in <body>. The second argument to createNexusAppearanceSnapshotFromCookie is the SSR fallback when the cookie is missing or invalid.

// app/layout.tsx
import {
  createNexusAppearanceSnapshotFromCookie,
  DEFAULT_COOKIE_KEY,
} from '@nexus_ds/core';
import type { Viewport } from 'next';
import { cookies } from 'next/headers';

import { NexusAppearanceProvider } from '@/components/appearance/provider';
import { NexusAppearanceScript } from '@/components/appearance/provider/server';

import { defaultAppearance } from './appearance-state';

import './globals.css';

export const viewport: Viewport = { colorScheme: 'light dark' };

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const cookieStore = await cookies();
  const snapshot = createNexusAppearanceSnapshotFromCookie(
    cookieStore.get(DEFAULT_COOKIE_KEY)?.value,
    defaultAppearance
  );

  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <NexusAppearanceScript
          storageKey="app-appearance"
          defaultState={snapshot.state}
        />
      </head>
      <body className="nx:bg-background nx:text-foreground">
        <NexusAppearanceProvider
          storageKey="app-appearance"
          cookieWriteKey={DEFAULT_COOKIE_KEY}
          defaultState={snapshot.state}
        >
          {children}
        </NexusAppearanceProvider>
      </body>
    </html>
  );
}

The cookie seeds SSR and first paint. cookieWriteKey writes the active client state back to a cookie for the next server request; the provider does not read the cookie again during client hydration. When storageKey is enabled, localStorage becomes the client-side source of truth after hydration. Use storageKey={false} when you want the client to hydrate from the server snapshot/default state without localStorage persistence.

Under a strict CSP, pass a nonce to <NexusAppearanceScript nonce={nonce} />.

Vite

Vite has no server render, so inject the first-paint script at build time with transformIndexHtml. The bootstrap and provider must use the same storage key.

// vite.config.ts
import {
  createNexusAppearanceBootstrapScript,
  createNexusAppearanceSnapshotFromState,
} from '@nexus_ds/core';
import react from '@vitejs/plugin-react';
import { defineConfig } from 'vite';

import { defaultAppearance } from './src/appearance-state';

const STORAGE_KEY = 'app-appearance';
const defaultSnapshot =
  createNexusAppearanceSnapshotFromState(defaultAppearance);

export default defineConfig({
  plugins: [
    react(),
    {
      name: 'nexus-appearance-bootstrap',
      transformIndexHtml(html) {
        const script = createNexusAppearanceBootstrapScript({
          storageKey: STORAGE_KEY,
          defaultSnapshot,
        });
        return html.replace('</head>', `<script>${script}</script></head>`);
      },
    },
  ],
});
// src/main.tsx
import { createRoot } from 'react-dom/client';

import App from './App';
import { defaultAppearance } from './appearance-state';
// the provider folder from Install, under src/ for a Vite app
import { createNexusAppearance } from './components/appearance/provider';

import './index.css';

const { NexusAppearanceProvider } = createNexusAppearance({
  storageKey: 'app-appearance',
  defaultState: defaultAppearance,
});

createRoot(document.getElementById('root')!).render(
  <NexusAppearanceProvider>
    <App />
  </NexusAppearanceProvider>
);

Do not wait for a client effect to apply Appearance. The script needs to run before the first frame: in SSR <head> for Next, or in transformIndexHtml for Vite.

Check your setup

Load the app and confirm each of these before moving on:

  • styles/nexus/nexus.css is imported from your CSS entry point
  • The provider wraps your tree, and the script runs in <head>
  • Reloading in dark mode produces no flash of the light theme1
  • Toggling the mode persists across a full page reload — storageKey does this on its own; cookieWriteKey needs a server that re-seeds defaultState from the cookie

Footnotes

  1. If the script is already in <head> and the flash persists, check that it and the provider were given the same storageKey — the script paints from that key in localStorage, so a mismatch means it restores one state and the provider hydrates another. ↩