Backend & Persistence

How to save documents, render them for end users, and run a multi-tenant SaaS on top of nuforge. The runtime is backend-agnostic — a document is just data you serialize, store, and load.

A runnable version of everything below lives in examples/03-backend (a Next.js app with an in-memory store, two tenants, and a save / publish editor): pnpm --filter @nuforge/example-backend dev.

What is a "document"?

A document is nu.state — the program plus any extension state. There are three serialization formats; pick per use case.

FormatRound-tripBest for
JSON (stable ids)t.flatten(nu.state)t.unflatten(...)Editor documents — node ids are preserved (history + collaboration stay intact). Recommended.
DSL textParser.stringify(nu.state.program)Parser.parseProgram(src)Git-friendly versioning, human review. Ids are regenerated on parse.
CRDT snapshotLoro doc.export({ mode: 'snapshot' })Real-time multi-user editing + persistence in one (see Collaboration).
import { Nu } from '@nuforge/core';
import * as t from '@nuforge/types';

// SAVE — a JSON-serializable, id-stable snapshot
const document = JSON.stringify(t.flatten(nu.state));

// LOAD
nu.load(t.unflatten(JSON.parse(document)) as t.State);

Extension state (themes, editor settings) is saved automatically — it lives in state.extensions[key].value, which is part of the State.

Persisting documents

A minimal Postgres schema (every row is tenant-scoped):

create table tenants (id uuid primary key, name text);

create table pages (
  id uuid primary key,
  tenant_id uuid not null references tenants(id),
  slug text not null,
  name text,
  document jsonb not null,        -- t.flatten(state)
  status text not null default 'draft',  -- 'draft' | 'published'
  version int not null default 1,
  updated_at timestamptz default now(),
  unique (tenant_id, slug)
);

create table page_versions (
  id uuid primary key,
  page_id uuid not null references pages(id),
  document jsonb not null,
  author_id uuid,
  created_at timestamptz default now()
);

Save and load through an API route:

// PUT /api/pages/:id  — body: { document: FlattenedState }
export async function savePage(id: string, document: unknown, tenantId: string) {
  await db.page.update({
    where: { id, tenantId },
    data: { document, version: { increment: 1 }, updatedAt: new Date() },
  });
  await db.pageVersion.create({ data: { pageId: id, document } });
}

// GET /api/pages/:id
export async function loadPage(id: string, tenantId: string) {
  const page = await db.page.findFirstOrThrow({ where: { id, tenantId } });
  return page.document; // FlattenedState — hand to t.unflatten on the client
}

Autosave

Subscribe to committed changes and debounce a save:

let timer: ReturnType<typeof setTimeout>;
nu.listenToChangeset(() => {
  clearTimeout(timer);
  timer = setTimeout(() => {
    fetch(`/api/pages/${pageId}`, {
      method: 'PUT',
      body: JSON.stringify({ document: t.flatten(nu.state) }),
    });
  }, 800);
});

Draft vs published & versioning

  • Keep a status column (or two rows). Publish copies the draft into the published slot (optionally pre-rendering it — see below).
  • Each meaningful save appends to page_versions. Restore is just nu.load(t.unflatten(version.document)).

Rendering pages for end users

Pick the delivery that fits the page.

  • SSR / RSC (dynamic, per-request data) — render with the server entry:
// app/[tenant]/[slug]/page.tsx — a React Server Component
import { Nu } from '@nuforge/core';
import { NuStatic } from '@nuforge/react/server';
import * as t from '@nuforge/types';

export default async function RenderPage({
  params,
}: {
  params: { tenant: string; slug: string };
}) {
  const page = await db.page.findFirstOrThrow({
    where: { tenantId: params.tenant, slug: params.slug, status: 'published' },
  });
  const data = await loadTenantData(params.tenant); // the page's dynamic data

  const nu = Nu.create({
    externals: {
      states: [t.externalState({ name: 'data', init: data })], // $data in the DSL
      components: tenantComponents(params.tenant), // <$Chart/> etc.
    },
  });
  nu.load(t.unflatten(page.document) as t.State);
  const frame = nu.createFrame({ component: { name: 'App' } });

  return <NuStatic frame={frame} />;
}

This is the bridge for dynamic data: the host fetches it and injects it as external state; the DSL reads $data. (The DSL itself can't fetch — by design; see Externals.)

  • Static HTML (CDN cache, email) — render once at publish time:
import { toHtml } from '@nuforge/codegen';
const html = toHtml(frame.view); // store/serve as a static page
  • Export to code (a static site / Next.js build) — toReact(program) emits a real .tsx component (with 'use client' only when needed). See Code Export.

Multi-tenancy

Two layers: data isolation (standard SaaS) and runtime isolation (nuforge's allow-list does the heavy lifting).

Data isolation

  • A tenant_id on every row plus row-level security (Postgres RLS) or query-layer scoping. A page document is opaque data like any other row.
  • Shared-DB + tenant_id is the common pattern; at large scale, schema- or DB-per-tenant.

Runtime isolation (why nuforge is safe here)

When you render tenant A's page, Nu.create({ externals }) contains only tenant A's data and components. Because the DSL is allow-listed and external state is deep-frozen, a page cannot reach beyond what you inject — so there's no cross-tenant leak by construction. Concretely:

  • Per-tenant data — inject only that tenant's values as externals.states.
  • Per-tenant blocks/componentseditor.blocks.register(...) and externals.components differ per tenant (e.g. their branded components).
  • Per-tenant theme/tokens — an external $theme state, or CSS variables in the canvas srcDoc (see Editor SDK).

Validate untrusted documents

If documents can come from an untrusted source, validate them against the schema before load. The parser and evaluator already block dangerous syntax and property access; for fully untrusted, third-party content add a sandbox at your extension points. See the Security Model.

Real-time collaboration (optional)

Loro gives you persistence and multi-user editing together:

import { LoroSyncProvider } from '@nuforge/collaboration';
import { LoroDoc } from 'loro-crdt';

const doc = new LoroDoc();
new LoroSyncProvider(nu, doc).init();

// Relay doc.export(update) between peers over WebSocket;
// snapshot doc.export({ mode: 'snapshot' }) to the DB periodically.

The server keeps the snapshot as the source of truth and broadcasts updates. See Collaboration.

Security checklist

  • ✅ Validate a document's schema before load / unflatten, especially from collaboration or import.
  • ✅ Inject only the current tenant's data as externals (isolation).
  • ✅ Scope every query by tenant_id (RLS or query layer).
  • ✅ Your external functions are your attack surface — don't expose dangerous capabilities (raw DB access, fs) to the DSL.
  • ⚠️ For fully untrusted multi-tenant content, add an extra sandbox at extension points.

Next steps