Skip to content

Next.js

Integrate StateLoom with Next.js, including App Router, server-side rendering, and scope isolation.

Data Flow (SSR)

Prerequisites

  • Next.js 14+ (App Router)
  • React 18+
  • Node.js 18+

Installation

bash
pnpm add @stateloom/core @stateloom/react @stateloom/store
bash
npm install @stateloom/core @stateloom/react @stateloom/store
bash
yarn add @stateloom/core @stateloom/react @stateloom/store

For SSR and persistence, add the server and persist packages:

bash
pnpm add @stateloom/server @stateloom/persist
# Optional: Redis persistence for production
pnpm add @stateloom/persist-redis
bash
npm install @stateloom/server @stateloom/persist

Basic Setup

Define Stores

Create stores at the module level. They work identically in client and server:

typescript
// src/state/counter.ts
import { signal, computed } from '@stateloom/core';

export const count = signal(0);
export const doubled = computed(() => count.get() * 2);

export function increment(): void {
  count.update((n) => n + 1);
}

export function decrement(): void {
  count.update((n) => n - 1);
}

export function reset(): void {
  count.set(0);
}

Client Components

Use stores in client components with hooks:

tsx
// src/components/Counter.tsx
'use client';

import { useSignal } from '@stateloom/react';
import { count, doubled, increment, decrement, reset } from '@/state/counter';

export function Counter() {
  const value = useSignal(count);
  const double = useSignal(doubled);

  return (
    <section>
      <h2>Counter</h2>
      <p>
        Count: {value} | Doubled: {double}
      </p>
      <button onClick={decrement}>-</button>
      <span>{value}</span>
      <button onClick={increment}>+</button>
      <button onClick={reset}>Reset</button>
    </section>
  );
}

SSR with Scope Isolation

The Problem

In Next.js server-side rendering, multiple requests can share the same Node.js process. Without isolation, signal values from one request leak into another.

The Solution: Server Scope

Use @stateloom/server to create scoped, per-request state:

typescript
// src/state/scope-setup.ts
import { signal } from '@stateloom/core';
import { createServerScope } from '@stateloom/server';

export const pageTitle = signal('StateLoom SSR Demo');
export const visitCount = signal(0);
export const serverTimestamp = signal('');

export const serverScope = createServerScope({
  ttl: 60_000, // 1 minute per request scope
  maxEntries: 1_000, // LRU capacity
  onEvict: (id) => {
    console.log(`[stateloom] Evicted scope: ${id}`);
  },
});

Server Components

Initialize scoped state in server components:

tsx
// src/app/ssr-demo/page.tsx
import { runInScope, serializeScope } from '@stateloom/core';
import { serverScope, pageTitle, visitCount, serverTimestamp } from '@/state/scope-setup';
import { ScopeHydrator } from '@/components/ScopeHydrator';
import { SSRContent } from './ssr-content';

export default async function SSRDemoPage() {
  // 1. Fork a per-request scope
  const reqScope = serverScope.fork();

  // 2. Initialize scoped state
  runInScope(reqScope, () => {
    reqScope.set(pageTitle, 'SSR Demo Page');
    reqScope.set(visitCount, Math.floor(Math.random() * 1000));
    reqScope.set(serverTimestamp, new Date().toISOString());
  });

  // 3. Serialize for client hydration
  const scopeData = {
    title: reqScope.get(pageTitle),
    visits: reqScope.get(visitCount),
    timestamp: reqScope.get(serverTimestamp),
    serialized: serializeScope(reqScope),
  };

  // 4. Clean up the server scope
  serverScope.dispose(reqScope.id);

  return (
    <ScopeHydrator scopeData={scopeData}>
      <SSRContent />
    </ScopeHydrator>
  );
}

Client Hydration

Read hydrated scope values in client components:

tsx
// src/app/ssr-demo/ssr-content.tsx
'use client';

import { useSignal, useScopeContext } from '@stateloom/react';
import { pageTitle, visitCount, serverTimestamp } from '@/state/scope-setup';

export function SSRContent() {
  const scope = useScopeContext();
  const title = useSignal(pageTitle);
  const visits = useSignal(visitCount);
  const timestamp = useSignal(serverTimestamp);

  return (
    <section>
      <h2>Client-Hydrated Data</h2>
      <ul>
        <li>Scope active: {scope ? 'Yes' : 'No'}</li>
        <li>Page Title: {title}</li>
        <li>Visit Count: {visits}</li>
        <li>Server Timestamp: {timestamp}</li>
      </ul>
    </section>
  );
}

Redis Persistence

For session or user state that survives restarts:

typescript
// src/stores/user-prefs.ts
import { createStore } from '@stateloom/store';
import { persist } from '@stateloom/persist';
import { redisStorage } from '@stateloom/persist-redis';
import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL!);

export function createUserPrefsStore(userId: string) {
  return createStore(
    (set) => ({
      theme: 'light' as 'light' | 'dark',
      locale: 'en',
      setTheme: (t: 'light' | 'dark') => set({ theme: t }),
      setLocale: (l: string) => set({ locale: l }),
    }),
    {
      middleware: [
        persist({
          key: `user-prefs:${userId}`,
          storage: redisStorage({ client: redis, ttl: 86400 }),
          partialize: (state) => ({
            theme: state.theme,
            locale: state.locale,
          }),
        }),
      ],
    },
  );
}

Middleware in Next.js

Development Devtools

Enable devtools only in development:

typescript
import { createStore } from '@stateloom/store';
import { devtools } from '@stateloom/devtools';

const store = createStore(
  (set) => ({
    count: 0,
    increment: () => set((s) => ({ count: s.count + 1 })),
  }),
  {
    middleware: [
      devtools({
        name: 'Counter',
        enabled: typeof window !== 'undefined' && process.env.NODE_ENV === 'development',
      }),
    ],
  },
);

Cross-Tab Sync

Sync state across browser tabs:

typescript
import { createStore } from '@stateloom/store';
import { broadcast } from '@stateloom/tab-sync';
import { persist } from '@stateloom/persist';

const settingsStore = createStore(
  (set) => ({
    theme: 'light' as 'light' | 'dark',
    setTheme: (t: 'light' | 'dark') => set({ theme: t }),
  }),
  {
    middleware: [persist({ key: 'settings' }), broadcast({ channel: 'settings' })],
  },
);

Troubleshooting

"Cannot read properties of undefined" during SSR

This usually means a browser API (window, localStorage) is accessed during server rendering. StateLoom middleware that uses browser APIs gracefully degrades to no-ops during SSR -- no conditional imports needed.

State leaking between requests

Always use scopes for per-request state isolation. Never mutate global store state in server components.

Large bundle size

Use Next.js dynamic imports to code-split components that depend on specific paradigm adapters:

tsx
import dynamic from 'next/dynamic';
const Counter = dynamic(() => import('./Counter'), { ssr: false });

Tips

Store Initialization on Server

Stores defined at module level are singletons. In SSR, use scopes to isolate per-request state. Never mutate global store state in server components.

Client-Only Middleware

Middleware that uses browser APIs (BroadcastChannel, localStorage, Redux DevTools) gracefully degrades to no-ops during SSR. No conditional imports needed.

Example App

See the complete Next.js SSR example for a working app that demonstrates server scopes, client hydration, and Redis persistence.

Next Steps