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
pnpm add @stateloom/core @stateloom/react @stateloom/storenpm install @stateloom/core @stateloom/react @stateloom/storeyarn add @stateloom/core @stateloom/react @stateloom/storeFor SSR and persistence, add the server and persist packages:
pnpm add @stateloom/server @stateloom/persist
# Optional: Redis persistence for production
pnpm add @stateloom/persist-redisnpm install @stateloom/server @stateloom/persistBasic Setup
Define Stores
Create stores at the module level. They work identically in client and server:
// 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:
// 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:
// 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:
// 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:
// 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:
// 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:
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:
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:
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
- React API Reference -- Full hooks documentation
- Server API Reference -- Server scope management
- Persist API Reference -- Storage backends
- Persist-Redis API Reference -- Redis backend