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.
| Format | Round-trip | Best for |
|---|---|---|
| JSON (stable ids) | t.flatten(nu.state) ↔ t.unflatten(...) | Editor documents — node ids are preserved (history + collaboration stay intact). Recommended. |
| DSL text | Parser.stringify(nu.state.program) ↔ Parser.parseProgram(src) | Git-friendly versioning, human review. Ids are regenerated on parse. |
| CRDT snapshot | Loro 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
statuscolumn (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 justnu.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.tsxcomponent (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_idon 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_idis 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/components —
editor.blocks.register(...)andexternals.componentsdiffer per tenant (e.g. their branded components). - Per-tenant theme/tokens — an external
$themestate, or CSS variables in the canvassrcDoc(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
- Externals — inject per-tenant data, functions, components.
- Code Export — publish to static HTML or React.
- Collaboration — real-time sync via Loro.
- Security Model — the guarantees behind the allow-list.