Skip to main content

Advanced

7 min read

SSR and Hydration

Render on the server, hydrate on the client.


Overview

Directive provides four mechanisms for populating a system with external state. Choose based on your use case:

MechanismWhenAsyncFramework
initialFactsConstruction timeNoAny
system.hydrate(loader)Before start()YesAny
system.restore(snapshot)After constructionNoAny
DirectiveHydrator + useHydratedSystemSSR renderNoReact, Vue, Solid
setHydrationSnapshot + useHydratedSystemSSR renderNoSvelte
HydrationControllerSSR renderNoLit

Server Rendering

Create a system per request, seed it with initialFacts, settle, snapshot, and destroy:

// server.ts
import { createSystem } from '@directive-run/core';

export async function renderPage(req) {
  const system = createSystem({
    module: pageModule,
    initialFacts: {
      userId: req.user?.id,
      path: req.path,
    },
  });
  system.start();

  // Block until all constraints evaluate and resolvers finish
  await system.settle();

  const snapshot = system.getSnapshot();
  const html = renderToString(<App system={system} />);

  system.stop();
  system.destroy();

  return { html, state: snapshot };
}

system.settle() waits until all active constraints have been evaluated and all in-flight resolvers have completed. Pass a timeout in milliseconds to prevent hanging:

try {
  await system.settle(5000);
} catch (err) {
  // SettleTimeoutError – includes details about what's still pending
  console.error('System did not settle in time:', err.message);
  // Render with partial state or return an error page
}

Client Hydration: initialFacts

The simplest hydration path. Works with every framework:

// client.ts
import { createSystem } from '@directive-run/core';

const system = createSystem({
  module: pageModule,
  initialFacts: window.__DIRECTIVE_STATE__.facts,
});
system.start();

hydrateRoot(
  document.getElementById('root'),
  <App system={system} />
);

initialFacts is applied during the init phase before the first reconciliation cycle, so the system starts with the correct state and avoids a flash of default values.


Client Hydration: system.hydrate()

Use hydrate() when the state source is async – localStorage, fetch, IndexedDB, etc.:

// client.ts
import { createSystem } from '@directive-run/core';

const system = createSystem({ module: pageModule });

await system.hydrate(async () => {
  const res = await fetch('/api/state');

  return res.json();
});

system.start();

hydrate() must be called before start(). It accepts a loader function that returns facts (sync or async). Hydrated facts take precedence over initialFacts.


React: DirectiveHydrator + useHydratedSystem

For React SSR and RSC, use DirectiveHydrator to pass a distributable snapshot from server to client, and useHydratedSystem to create a hydrated system from it.

Server component:

import { createSystem } from '@directive-run/core';

export async function getServerSnapshot() {
  const system = createSystem({
    module: pageModule,
    initialFacts: { userId: 'user-1' },
  });
  system.start();
  await system.settle();

  const snapshot = system.getDistributableSnapshot({
    includeDerivations: ['displayName', 'isReady'],
    includeFacts: ['userId', 'profile'],
    ttlSeconds: 300,
  });

  system.stop();
  system.destroy();

  return snapshot;
}

Client component:

'use client';

import { DirectiveHydrator, useHydratedSystem, useFact } from '@directive-run/react';

function ClientApp() {
  const system = useHydratedSystem(pageModule);
  const profile = useFact(system, 'profile');

  return <div>{profile.name}</div>;
}

// In the parent (server or client):
export default async function Page() {
  const snapshot = await getServerSnapshot();

  return (
    <DirectiveHydrator snapshot={snapshot}>
      <ClientApp />
    </DirectiveHydrator>
  );
}

useHydratedSystem extracts facts from the snapshot's data field and passes them as initialFacts to a new system. The system is created once and reused across re-renders.


Next.js Integration

The previous example had a broken pattern – passing a non-serializable system object across the RSC boundary. Here's the correct approach:

Server Component (app/page.tsx):

import { createSystem } from '@directive-run/core';
import { DirectiveHydrator } from '@directive-run/react';
import { ClientPage } from './client-page';

export default async function Page() {
  const system = createSystem({
    module: pageModule,
    initialFacts: { path: '/dashboard' },
  });
  system.start();
  await system.settle();

  const snapshot = system.getDistributableSnapshot({
    includeDerivations: ['isReady'],
    includeFacts: ['path', 'user'],
  });

  system.stop();
  system.destroy();

  return (
    <DirectiveHydrator snapshot={snapshot}>
      <ClientPage />
    </DirectiveHydrator>
  );
}

Client Component (app/client-page.tsx):

'use client';

import { useHydratedSystem, useFact, useDerived } from '@directive-run/react';

export function ClientPage() {
  const system = useHydratedSystem(pageModule);
  const user = useFact(system, 'user');
  const isReady = useDerived(system, 'isReady');

  if (!isReady) {
    return <div>Loading...</div>;
  }

  return <div>Welcome, {user.name}</div>;
}

Key points:

  • Only serializable data (the snapshot) crosses the RSC boundary – never a system instance
  • The server system is destroyed after extracting the snapshot
  • The client system is created fresh via useHydratedSystem

Express / Fastify

Directive works with any Node.js HTTP framework. Create a system per request, seed facts via initialFacts, settle, and return JSON:

import express from 'express';
import { createSystem } from '@directive-run/core';

const app = express();

app.get('/api/user/:id', async (req, res) => {
  const system = createSystem({
    module: userModule,
    initialFacts: { userId: req.params.id },
  });
  system.start();

  try {
    await system.settle(5000);
    res.json(system.getSnapshot());
  } catch (err) {
    res.status(504).json({ error: 'System did not settle in time' });
  } finally {
    system.stop();
    system.destroy();
  }
});

The same pattern works with Fastify, Hono, Koa, or any framework that supports async handlers.


Distributable Snapshots for APIs

For API responses, prefer getDistributableSnapshot() over getSnapshot(). Distributable snapshots include computed derivations and support TTL expiry:

await system.settle(5000);

const snapshot = system.getDistributableSnapshot({
  includeDerivations: ['effectivePlan', 'canUseFeature'],
  includeFacts: ['userId', 'plan'],
  ttlSeconds: 3600,
});

// Cache in Redis, serve from CDN, or return directly
res.json(snapshot);

Use the snapshot utility functions to validate cached snapshots:

import { isSnapshotExpired, validateSnapshot } from '@directive-run/core';

// Check if expired (returns boolean)
if (isSnapshotExpired(cachedSnapshot)) {
  // Re-fetch or re-generate
}

// Validate and return data (throws if expired)
try {
  const data = validateSnapshot(cachedSnapshot);
  res.json(data);
} catch (err) {
  // Snapshot expired – regenerate
}

Snapshot Types

Directive has two snapshot types:

TypeContentsUse Case
SystemSnapshotFacts onlygetSnapshot() / restore() – internal state transfer
DistributableSnapshotFacts + derivations + metadata + TTLgetDistributableSnapshot() – APIs, caching, DirectiveHydrator

SystemSnapshot is a plain object of fact key-value pairs. DistributableSnapshot adds createdAt, expiresAt, data (selected derivations/facts), and optional version for conflict detection.


Vue: DirectiveHydrator + useHydratedSystem

For Nuxt or Vue SSR apps, use DirectiveHydrator to provide a snapshot and useHydratedSystem to consume it.

<!-- layouts/default.vue -->
<template>
  <DirectiveHydrator :snapshot="snapshot">
    <slot />
  </DirectiveHydrator>
</template>

<script setup>
import { DirectiveHydrator } from "@directive-run/vue";

const props = defineProps<{ snapshot: Record<string, unknown> }>();
</script>
<!-- pages/index.vue -->
<script setup>
import { useHydratedSystem, useDerived } from "@directive-run/vue";

const system = useHydratedSystem(pageModule);
const profile = useDerived(system, "profile");
</script>

<template>
  <div>{{ profile.name }}</div>
</template>

Svelte: setHydrationSnapshot + useHydratedSystem

For SvelteKit, call setHydrationSnapshot in your layout and useHydratedSystem in child components.

<!-- +layout.svelte -->
<script>
  import { setHydrationSnapshot } from "@directive-run/svelte";
  export let data; // from SvelteKit load()
  setHydrationSnapshot(data.snapshot);
</script>
<slot />
<!-- +page.svelte -->
<script>
  import { useHydratedSystem, useDerived } from "@directive-run/svelte";
  const system = useHydratedSystem(pageModule);
  const profile = useDerived(system, "profile");
</script>

<div>{$profile.name}</div>

Solid: DirectiveHydrator + useHydratedSystem

For SolidStart, wrap your app with DirectiveHydrator and use useHydratedSystem in children.

// root.tsx
import { DirectiveHydrator } from "@directive-run/solid";

export default function Root(props: { snapshot: Record<string, unknown> }) {
  return (
    <DirectiveHydrator value={props.snapshot}>
      <App />
    </DirectiveHydrator>
  );
}
// App.tsx
import { useHydratedSystem, useDerived } from "@directive-run/solid";

function App() {
  const system = useHydratedSystem(pageModule);
  const profile = useDerived(system, "profile");
  return <div>{profile().name}</div>;
}

Lit: HydrationController

For Lit SSR, use HydrationController to create a hydrated system inside a web component.

import { LitElement, html } from "lit";
import { HydrationController, getDerived } from "@directive-run/lit";

class MyPage extends LitElement {
  private hydration = new HydrationController(this, serverSnapshot);
  private system = this.hydration.createSystem(pageModule);

  render() {
    const profile = getDerived(this.system, "profile");
    return html`<div>${profile?.name}</div>`;
  }
}

The controller automatically destroys the system when the element disconnects.


Error Handling

settle() Timeout

system.settle(timeoutMs) throws if the system doesn't settle within the timeout. The error includes details about what's still pending:

try {
  await system.settle(5000);
} catch (err) {
  // err.message includes pending resolver/constraint info
  console.error('SSR settle failed:', err.message);

  // Option 1: Return partial state
  const snapshot = system.getSnapshot();
  return { html: renderFallback(), state: snapshot };

  // Option 2: Return error page
  return { html: renderError(), state: null };
}

hydrate() Loader Errors

If the hydrate() loader throws, the error propagates to the caller. The system remains in a pre-start state and can be started without hydrated data:

try {
  await system.hydrate(async () => {
    const res = await fetch('/api/state');

    return res.json();
  });
} catch (err) {
  console.warn('Hydration failed, starting with defaults:', err);
}
system.start();

Avoiding Singletons

Never use module-level systems in SSR. A singleton system is shared across all concurrent requests on the server, causing state from one user's request to leak into another's:

// BAD – shared across all server requests
const system = createSystem({ module });

export function handler(req) {
  system.facts.userId = req.user.id; // Overwrites for ALL concurrent requests
}

// GOOD – factory function creates an isolated system per request
export function handler(req) {
  const system = createSystem({
    module,
    initialFacts: { userId: req.user.id },
  });
  system.start();
  // ... use, then destroy
  system.stop();
  system.destroy();
}

Next Steps

Previous
History & Snapshots

Stay in the loop. Sign up for our newsletter.

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven Runtime for TypeScript | AI Guardrails & State Management