@cdr-kit/forms
Drop <CdrForm> + a few <CdrField>s into any page; each submission gets its own CDR vault on Aeneid. Only the form creator can decrypt. Respondents never connect a wallet — gas + signature + storage upload are the server's burden (the platform-wallet pattern, extracted from Confide).
Storage adapter selection is a developer concern: you wire one of the six CdrStorageProvider factories from @cdr-kit/core into your server route once, with credentials from env vars. The respondent UI never sees a "pick a storage backend" choice — that would be a UX smell.
Live preview
<CdrForm onEncrypt={(fields) => fetch("/api/respond", { method: "POST", body: JSON.stringify({ fields }) }).then(r => r.json()).then(d => d.vaultId)}>
<CdrField name="mood" label="Mood (1–10)" type="number" required />
<CdrField name="highlight" label="Highlight of the week" required />
<CdrField name="notes" label="Anything else?" type="textarea" />
<CdrSubmitButton>Submit securely</CdrSubmitButton>
</CdrForm>This preview runs the real @cdr-kit/forms components against an in-memory mock onEncrypt that returns a fake vault uuid after a short delay — same UX as the live network round-trip, no wallet needed to see it.
Install
pnpm add @cdr-kit/forms @cdr-kit/agent @cdr-kit/core
Client quickstart
"use client";
import { CdrForm, CdrField, CdrSubmitButton } from "@cdr-kit/forms";
import "@cdr-kit/forms/styles.css";
export function Survey() {
return (
<CdrForm
onEncrypt={async (fields) => {
const r = await fetch("/api/respond", {
method: "POST",
body: JSON.stringify({ fields }),
});
const { vaultId } = await r.json();
return vaultId;
}}
onSuccess={(uuid) => console.log("stored at vault", uuid)}
>
<CdrField name="mood" label="Mood (1–10)" type="number" required />
<CdrField name="highlight" label="Highlight of the week" required />
<CdrField name="notes" label="Anything else?" type="textarea" />
<CdrSubmitButton />
</CdrForm>
);
}<CdrForm> serializes every <CdrField> (via FormData) and calls your onEncrypt with the resulting object. It expects you to return the CDR vault UUID — that's what gets persisted in your DB so you can decrypt later.
Server quickstart
The server route is where you wire your storage adapter. Here it's Pinata:
// app/api/respond/route.ts
import { NextResponse } from "next/server";
import { createPinataStorage } from "@cdr-kit/core";
import { storeFormSubmission } from "@cdr-kit/forms/server";
export async function POST(req: Request) {
const { fields } = await req.json();
const storage = createPinataStorage({
jwt: process.env.PINATA_JWT!,
gatewayUrl: process.env.PINATA_GATEWAY_URL ?? "https://gateway.pinata.cloud",
});
const { vaultId, cid } = await storeFormSubmission(fields, {
privateKey: process.env.WALLET_PRIVATE_KEY as `0x${string}`,
storage,
rpcUrl: "https://aeneid.storyrpc.io",
});
return NextResponse.json({ vaultId, cid });
}To read the submission back, use the same storage adapter:
import { readFormSubmission } from "@cdr-kit/forms/server";
const { fields, submittedAt } = await readFormSubmission(vaultId, {
privateKey: process.env.WALLET_PRIVATE_KEY as `0x${string}`,
storage, // same adapter as the write
});The six storage adapters
@cdr-kit/core ships six CdrStorageProvider factories. Pick the one that matches your existing infra; swap by changing the factory call in your server route — no other code changes required.
| Prop | Type | Default | Description |
|---|---|---|---|
| createPinataStorage | (opts: { jwt, gatewayUrl? }) => CdrStorageProvider | — | Hosted IPFS pinning. Free signup at pinata.cloud. Simplest setup — one JWT env var. |
| createSupabaseStorage | (opts: { supabaseUrl, key, bucket, pathPrefix?, bucketIsPublic? }) => CdrStorageProvider | — | Postgres-backed object storage. Best if you already use Supabase Auth/DB. Service-role or anon key. |
| createIpfsStorage | (opts: { addUrl, gatewayUrl, headers? }) => CdrStorageProvider | — | Any IPFS-compatible HTTP API (kubo, your own gateway, etc.). |
| createS3Storage | (opts: { bucket, region, accessKeyId, secretAccessKey, endpoint?, pathPrefix?, forcePathStyle? }) => CdrStorageProvider | — | AWS S3, Cloudflare R2, Backblaze B2 — anything S3-compatible. Lazy-loads @aws-sdk/client-s3. |
| createStorachaStorage | (opts: { key, spaceDid, proof, gatewayUrl? }) => CdrStorageProvider | — | Storacha / web3.storage UCAN-pinning. key + proof via w3 CLI. Lazy-loads @storacha/client. |
| createHeliaStorage | (opts?: { helia? }) => CdrStorageProvider | — | Self-hosted in-process Helia node. Good for local dev, no third-party deps. |
<CdrForm> props
| Prop | Type | Default | Description |
|---|---|---|---|
| onEncryptrequired | (fields: CdrFormFields) => Promise<number> | — | Called with the serialized field map on submit. Returns the CDR vault uuid. Server-side encryption happens inside this callback (typically a POST to /api/respond). |
| onSuccess | (vaultId: number) => void | — | Fired after onEncrypt resolves. Receives the same vault uuid the callback returned. |
| onError | (err: Error) => void | — | Fired if onEncrypt rejects. Pair with form-level error UI. |
| childrenrequired | ReactNode | — | CdrFields, CdrSubmitButton, and any layout you want around them. |
<CdrField> props
| Prop | Type | Default | Description |
|---|---|---|---|
| namerequired | string | — | FormData key. Becomes a top-level property in the encrypted JSON payload. |
| label | string | — | Visible label above the input. |
| type | CdrFieldType | "text" | One of: text · textarea · email · number · select · radio · checkbox. |
| placeholder | string | — | Placeholder for text-like inputs. |
| required | boolean | false | HTML5 required attribute. |
| options | { value: string; label: string }[] | — | Required for select / radio / checkbox; ignored otherwise. |
<StorageProviderPicker>
Admin-side tile-grid for picking which CdrStorageProvider your platform routes form submissions through. Not for respondent UIs — render this in your setup screen, then wire the picked id to the matching factory on the server.
import { StorageProviderPicker } from "@cdr-kit/forms";
import { useState } from "react";
const [provider, setProvider] = useState("pinata");
return <StorageProviderPicker value={provider} onChange={setProvider} />;| Prop | Type | Default | Description |
|---|---|---|---|
| value | StorageProviderId | undefined | — | Currently-selected provider id. Pass undefined for 'none chosen'. |
| onChangerequired | (id: StorageProviderId) => void | — | Fires when the user clicks a tile. |
| include | readonly StorageProviderId[] | — | Restrict to a subset of providers. Order is respected. Default: all 8. |
| heading | ReactNode | null | "Storage backend" | Visible heading above the tiles. Pass null to hide. |
| className | string | — | Forwarded to the root container. |
| style | CSSProperties | — | Forwarded inline style. |
The StorageProviderId union covers every adapter shipped by @cdr-kit/core: "pinata", "supabase", "storacha", "ipfs", "s3", "helia", "gateway", "memory". Each tile renders a brand-colored cdr-kit-authored glyph (not the provider's official mark — keeps licensing clean) and a one-line description.
<CdrField> type values
| Prop | Type | Default | Description |
|---|---|---|---|
| text | CdrFieldType | — | Single-line input. |
| textarea | CdrFieldType | — | Multi-line textarea. |
| CdrFieldType | — | Email input with HTML5 validation. | |
| number | CdrFieldType | — | Number input. |
| select | CdrFieldType | — | Dropdown — pass `options={[{value, label}]}`. |
| radio | CdrFieldType | — | Radio group — pass `options={[{value, label}]}`. |
| checkbox | CdrFieldType | — | Checkbox group — pass `options={[{value, label}]}`. |
<CdrSubmitButton> props
| Prop | Type | Default | Description |
|---|---|---|---|
| children | ReactNode | "Submit securely" | Button label while idle. Auto-swapped to “Encrypting…” during submit and “Submitted ✓” after success. |
useCdrSubmit() (low-level)
If you don't want the <CdrForm> wrapper, you can call useCdrSubmit({ onEncrypt }) directly to drive your own form layout. It returns { submit, isLoading, error, vaultId, reset }.
import { useCdrSubmit } from "@cdr-kit/forms";
function MyForm() {
const { submit, isLoading, vaultId } = useCdrSubmit({
onEncrypt: async (fields) => {
const r = await fetch("/api/respond", { method: "POST", body: JSON.stringify(fields) });
return (await r.json()).vaultId;
},
});
return (
<form onSubmit={(e) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
submit(data);
}}>
{/* ...your own inputs... */}
<button disabled={isLoading}>{vaultId ? "Submitted" : "Submit"}</button>
</form>
);
}Scaffolder
A working end-to-end forms app — Pinata wired, /api/respond + /api/results routes, dark premium layout — is one command away:
pnpm create cdr-kit-app my-forms --template forms
The generated app's lib/storage.ts is the single source of truth for which adapter is wired; comments inline show how to swap to any of the other five. See the scaffolder docs for other templates.