formakit-panel
v0.3.3
Published
Admin panel for browsing, editing, and previewing FormaKit form configs
Maintainers
Readme
formakit-panel
A React admin panel for browsing, editing, and live-previewing FormaKit form configs. Drop it into any Next.js or React app to give non-developers a visual editor for forms defined as JSON.
Features
- Sidebar — searchable list of forms with create / delete
- Form editor — name, description, validation mode settings
- Layout tree — add / remove rows, columns, and fields; per-breakpoint span & offset
- Field editor — type, label, placeholder, validation rules, select options
- Initial values — edit default field values
- JSON tab — copy / import
FormConfigJSON - Live preview — renders with real
FormBuilder; XS / SM / MD / LG / XL breakpoint switcher - Resizable panels — drag to resize sidebar, editor, and preview
- Two persistence modes — callback registry or built-in REST client
- Headless hook —
usePanelStatefor custom UIs - Server validators — Zod schemas for API routes (
formakit-panel/server)
Requirements
| Dependency | Version |
|------------|---------|
| react | ^18.2 or ^19 |
| react-dom | ^18.2 or ^19 |
| formakit | >=0.1.0 |
No Tailwind or shadcn in your host app. The panel ships a pre-built stylesheet (formakit-panel/styles.css) with all layout utilities and theme tokens scoped to .formakit-panel-root.
Installation
npm install formakit formakit-panel
# or
bun add formakit formakit-panel1. Import styles (required)
import "formakit-panel/styles.css";2. Render the panel
"use client";
import { FormakitPanel } from "formakit-panel";
import "formakit-panel/styles.css";
export default function AdminPage() {
return (
<div style={{ height: "100vh", width: "100%" }}>
<FormakitPanel storage={{ type: "api", baseUrl: "/api" }} />
</div>
);
}Use a full-height wrapper so the resizable layout works. Inline height: 100vh works in apps without Tailwind.
Optional: Tailwind host apps
If your app already uses Tailwind, you do not need @source for this package — the bundled CSS is sufficient.
Quick start (registry mode)
Registry mode means your app owns the form list and persistence. The panel calls your callbacks when the user saves, creates, or deletes.
"use client";
import { useState, useCallback } from "react";
import { FormakitPanel, type FormDefinition } from "formakit-panel";
import "formakit-panel/styles.css";
const initialForms: FormDefinition[] = [
{
id: "login",
name: "Login",
description: "User authentication",
schema: {
id: "login",
initialValues: { email: "", password: "", remember: false },
mode: {
defaultTrigger: ["blur", "submit"],
revalidateAfterSubmit: "change",
touchStrategy: "blur",
},
rows: [
{
id: "login-row",
columns: [
{
id: "login-column",
span: 12,
items: [
{
kind: "field",
span: 12,
field: {
name: "email",
type: "email",
label: "Email",
placeholder: "[email protected]",
validation: {
rules: [
{ type: "required", message: "Email is required" },
{ type: "email", message: "Enter a valid email" },
],
},
},
},
{
kind: "field",
span: 12,
field: {
name: "password",
type: "password",
label: "Password",
validation: {
rules: [{ type: "required", message: "Password is required" }],
},
},
},
],
},
],
},
],
},
},
];
export function FormsAdminPage() {
const [forms, setForms] = useState(initialForms);
const onSave = useCallback(async (updated: FormDefinition) => {
setForms((all) => all.map((f) => (f.id === updated.id ? updated : f)));
// Persist to your database:
// await fetch(`/api/forms/${updated.id}`, { method: "PUT", body: JSON.stringify(updated) });
}, []);
const onCreate = useCallback(async (created: FormDefinition) => {
setForms((all) => [...all, created]);
// await fetch("/api/forms", { method: "POST", body: JSON.stringify(created) });
}, []);
const onDelete = useCallback(async (id: string) => {
setForms((all) => all.filter((f) => f.id !== id));
// await fetch(`/api/forms/${id}`, { method: "DELETE" });
}, []);
return (
<FormakitPanel
forms={forms}
onSave={onSave}
onCreate={onCreate}
onDelete={onDelete}
/>
);
}API mode (built-in REST client)
Pass a storage prop instead of forms + callbacks. The panel fetches and persists forms over HTTP.
<FormakitPanel
storage={{
type: "api",
baseUrl: "/api",
bodyFormat: "config", // default — same shape as the JSON tab
headers: { Authorization: "Bearer …" }, // optional
}}
/>REST contract (bodyFormat: "config" — default)
Matches the JSON tab and what FormBuilder / useFormConfig consume:
| Method | Path | Body | Response |
|--------|------|------|----------|
| GET | /forms | — | FormConfigSchema[] |
| GET | /forms/:id | — | FormConfigSchema |
| POST | /forms | FormConfigSchema | FormConfigSchema (optional) |
| PUT | /forms/:id | FormConfigSchema | — or updated config |
| DELETE | /forms/:id | — | 204 |
FormConfigSchema = { id?, initialValues?, mode?, rows } — no name / description wrapper.
Panel name and description (Form tab) are sent as request headers on save/create:
X-Formakit-NameX-Formakit-Description(optional)
Your API should persist those in an index/manifest alongside the config file.
Use bodyFormat: "definition" for the legacy wrapped shape ({ id, name, description, schema }).
Next.js App Router example (bodyFormat: "config")
// app/api/forms/route.ts
import { parseFormConfigSchema } from "formakit-panel/server";
export async function GET() {
const configs = await readAllFormConfigs(); // your storage
return Response.json(configs);
}
export async function POST(request: Request) {
const config = parseFormConfigSchema(await request.json());
const name = request.headers.get("X-Formakit-Name") ?? config.id ?? "Untitled";
await writeFormConfig(config, { name });
return Response.json(config, { status: 201 });
}// app/api/forms/[id]/route.ts
import { parseFormConfigSchema } from "formakit-panel/server";
export async function GET(request: Request, { params }: { params: { id: string } }) {
const config = await readFormConfig(params.id);
if (!config) return Response.json({ error: "Not found" }, { status: 404 });
const meta = await readFormMeta(params.id);
return Response.json(config, {
headers: meta?.name ? { "X-Formakit-Name": meta.name } : undefined,
});
}
export async function PUT(request: Request, { params }: { params: { id: string } }) {
const config = parseFormConfigSchema(await request.json());
await writeFormConfig(params.id, config, {
name: request.headers.get("X-Formakit-Name") ?? undefined,
description: request.headers.get("X-Formakit-Description") ?? undefined,
});
return Response.json(config);
}Custom storage adapter
For Firebase, Supabase, Prisma, or any backend, implement FormStorageAdapter:
import {
createStorage,
type FormStorageAdapter,
type FormDefinition,
} from "formakit-panel";
const adapter: FormStorageAdapter = {
async list() {
const res = await fetch("/api/forms");
return res.json();
},
async get(id) {
const res = await fetch(`/api/forms/${id}`);
return res.json();
},
async save(definition) {
await fetch(`/api/forms/${definition.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(definition),
});
},
async create(definition) {
await fetch("/api/forms", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(definition),
});
},
async delete(id) {
await fetch(`/api/forms/${id}`, { method: "DELETE" });
},
};
// Use with the headless hook:
import { usePanelState } from "formakit-panel";
function MyCustomPanel() {
const panel = usePanelState({ storage: adapter });
// panel.draft, panel.saveDraft(), panel.allForms, …
}Factory helpers:
import { createStorage, createRegistryStorage, createApiStorage } from "formakit-panel";
createStorage({ type: "registry", forms: [], onSave, onCreate, onDelete });
createStorage({ type: "api", baseUrl: "/api" });Data model
FormDefinition (panel registry entry)
Used in the sidebar and persistence layer:
interface FormDefinition {
id: string;
name: string;
description?: string;
schema: FormConfigSchema;
}FormConfigSchema (JSON-safe config)
Matches FormaKit's FormConfig<T> shape, stored inside schema. Safe to JSON.stringify (pattern rules use string regex, not RegExp):
interface FormConfigSchema {
id?: string;
initialValues?: Record<string, unknown>;
mode?: {
defaultTrigger?: ("change" | "blur" | "submit") | ("change" | "blur" | "submit")[];
revalidateAfterSubmit?: "change" | "blur" | "change-or-blur";
touchStrategy?: "blur" | "change" | "submit";
validateOnMount?: boolean;
};
rows: FormRowSchema[];
}JSON tab export shape
The JSON tab exports only the FormConfig shape (no name / description wrapper):
{
"id": "login",
"initialValues": { "email": "", "password": "", "remember": false },
"mode": {
"defaultTrigger": ["blur", "submit"],
"revalidateAfterSubmit": "change",
"touchStrategy": "blur"
},
"rows": [ … ]
}Import accepts either this shape or a legacy full FormDefinition (uses inner schema automatically).
Runtime conversion
import { schemaToFormConfig } from "formakit-panel";
import { FormBuilder } from "formakit";
const config = schemaToFormConfig(form.schema);
<FormBuilder config={config} />FormakitPanel props
| Prop | Type | Description |
|------|------|-------------|
| forms | FormDefinition[] | Initial forms (registry mode) |
| storage | { type: "api"; baseUrl: string; headers? } | API mode (alternative to forms) |
| onSave | (form) => void \| Promise<void> | Called when user saves an existing form |
| onCreate | (form) => void \| Promise<void> | Called when user saves a newly created form |
| onDelete | (id) => void \| Promise<void> | Called when user deletes a form |
| className | string | Extra class on root element |
Responsive preview breakpoints
The live preview uses an iframe so FormaKit media queries apply correctly:
| Button | Width | FormaKit breakpoint | |--------|-------|---------------------| | XS | 375px | < 640px | | SM | 640px | ≥ 640px | | MD | 768px | ≥ 768px | | LG | 1024px | ≥ 1024px | | XL | 1280px | ≥ 1280px |
Column and field span / offset can be set per breakpoint in the Fields tab.
Package exports
Client (formakit-panel)
| Export | Description |
|--------|-------------|
| FormakitPanel | Main UI component |
| usePanelState | Headless state management |
| createStorage | Storage factory |
| createRegistryStorage | In-memory + callbacks |
| createApiStorage | REST client |
| schemaToFormConfig | Schema → FormConfig for FormBuilder |
| formConfigSchemaToJson | Pretty-print schema JSON |
| parseFormConfigJson | Parse / validate pasted JSON |
| mergeImportedConfig | Apply imported config to a form |
| createEmptyFormDefinition | Blank form template |
| createEmptyFormConfigSchema | Blank schema |
| createEmptyFieldItem | Blank field item |
| parseFormDefinition | Zod parse full definition |
| parseFormConfigSchema | Zod parse config only |
| formDefinitionValidator | Zod schema object |
| formConfigSchemaValidator | Zod schema object |
Server (formakit-panel/server)
Tree-shakeable entry without React — use in API routes:
import {
formDefinitionValidator,
parseFormDefinition,
schemaToFormConfig,
type FormDefinition,
} from "formakit-panel/server";Supported field types
text · email · password · number · textarea · select · radio · checkbox · switch · date · time
Validation rules
required · email · minLength · maxLength · min · max · pattern (string regex)
Development
git clone <your-repo>
cd packages/formakit-panel
npm install
npm run build # compiles to dist/
npm run dev # tsc --watchLicense
MIT
