FieldCraftDocsServicesBlogWork With Me →

Draft Persistence

Save and resume in-progress forms.

FieldCraft can auto-save form progress to localStorage, a server-side adapter, or both. When a user returns, the engine restores their values, current section, and visited sections from the saved snapshot.

Enable Drafts

Set allowDraftSave in your schema settings:

const schema: FormEngineSchema = {
id: "application",
version: "1.0.0",
title: "Application Form",
submitAction: { type: "callback" },
settings: {
allowDraftSave: true,
draftStorage: "local", // "local" | "server" | "both"
draftTtlHours: 168, // 7 days (default: 72 hours)
},
sections: [/* ... */],
};

Storage Options

ModeDescription
"local"localStorage only. Fast, no server needed. Data stays on-device.
"server"Server-side only via a DraftAdapter. Works across devices.
"both"Saves to both. On load, checks localStorage first, then server.

What Gets Saved

A draft snapshot contains:

type DraftSnapshot = {
values: Record<string, unknown>; // All field values
currentSectionId: string; // Where the user left off
visitedSectionIds: string[]; // Navigation history
savedAt: string; // ISO 8601 timestamp
};

Engine API

const engine = createEngine(schema);

// Save current state as a draft
await engine.saveDraft();

// Load a previously saved draft (returns true if found)
const restored = await engine.loadDraft();

// Delete the saved draft
engine.clearDraft();

// Check draft state
const state = engine.getState();
state.hasDraft; // boolean
state.lastDraftSavedAt; // string | undefined (ISO 8601)

TTL (Time-to-Live)

Drafts expire after draftTtlHours (default: 72 hours). On load, the engine checks whether the draft has expired. Expired drafts are discarded and loadDraft() returns false.

settings: {
allowDraftSave: true,
draftTtlHours: 24, // Expire after 1 day
}

LocalStorage Keys

When using "local" storage, drafts are stored under the key pattern fe_draft__{schemaId}__{sessionToken}. The sessionToken is passed when creating the engine:

const engine = createEngine(schema, {
sessionToken: "user-abc-123",
});

// Stored as: fe_draft__application__user-abc-123

Server-Side Drafts

For cross-device persistence, implement a DraftAdapter and pass it when creating the engine:

import type { DraftAdapter, DraftData } from "@squaredr/fieldcraft-core";

const myAdapter: DraftAdapter = {
async save(draft: DraftData) {
await fetch("/api/drafts", {
method: "POST",
body: JSON.stringify(draft),
});
},

async load(schemaId: string, sessionToken: string) {
const res = await fetch(`/api/drafts/${schemaId}/${sessionToken}`);
if (!res.ok) return null;
return res.json();
},

async delete(schemaId: string, sessionToken: string) {
await fetch(`/api/drafts/${schemaId}/${sessionToken}`, {
method: "DELETE",
});
},
};

const engine = createEngine(schema, {
sessionToken: "user-abc-123",
draftAdapter: myAdapter,
});

DraftData Shape

type DraftData = {
schemaId: string;
sessionToken: string;
partialData: Record<string, unknown>;
currentSectionId?: string;
visitedSectionIds?: string[];
savedAt: string; // ISO 8601
expiresAt: string; // ISO 8601
};

Built-in Adapters

The storage adapter packages include draft support out of the box:

PackageDraft Support
@squaredr/fieldcraft-supabaseUpserts to a drafts table with RLS
@squaredr/fieldcraft-postgresUpserts via Drizzle ORM with TTL column
@squaredr/fieldcraft-webhookNot applicable (webhooks are fire-and-forget)
import { createSupabaseAdapter } from "@squaredr/fieldcraft-supabase";

const adapter = createSupabaseAdapter({
supabaseUrl: process.env.SUPABASE_URL!,
supabaseKey: process.env.SUPABASE_ANON_KEY!,
});

const engine = createEngine(schema, {
sessionToken: "user-abc-123",
draftAdapter: adapter.draftAdapter,
adapters: [adapter],
});

Resume Prompt

When loadDraft() finds a valid draft, you can show a prompt letting the user choose to resume or start fresh:

function MyForm({ schema }: { schema: FormEngineSchema }) {
const [engine] = useState(() => createEngine(schema, { sessionToken: "user-123" }));
const [showResume, setShowResume] = useState(false);

useEffect(() => {
engine.loadDraft().then((found) => {
if (found) setShowResume(true);
});
}, [engine]);

if (showResume) {
return (
<div>
<p>You have a saved draft. Resume where you left off?</p>
<button onClick={() => setShowResume(false)}>Resume</button>
<button onClick={() => { engine.clearDraft(); setShowResume(false); }}>
Start Over
</button>
</div>
);
}

return <FormEngineRenderer engine={engine} />;
}