A multi-tenant form platform: every company gets a workspace, every workspace gets a multi-step form builder with 23 field types, and every form gets a public link, a submissions store, a REST API and webhooks on each event.
- Frontend — Next.js 16 (App Router) + shadcn/ui (
base-novastyle, built on Base UI) - Backend — Convex only: database, queries/mutations/actions, file storage, scheduling, HTTP endpoints and authentication
bun install
npx convex dev # deploys convex/ and watches for changes
bun dev # http://localhost:3000.env.local needs NEXT_PUBLIC_CONVEX_URL and NEXT_PUBLIC_CONVEX_SITE_URL.
Magic Forms signs its own access tokens, so each deployment needs a signing key. One command generates the keypair and stores it — there is nothing to copy by hand:
node scripts/setup-auth-keys.mjsIt sets JWT_PRIVATE_KEY, JWKS and JWT_KID on the deployment npx convex
is currently pointed at, refuses to overwrite keys that already look valid, and
reads them back to confirm. Pass --force to rotate, which signs everyone out.
If sign-in fails with
Failed to execute 'atob',JWT_PRIVATE_KEYis not valid base64 — run the script again to repair it.
Create the platform administrator (never exposed to clients):
npx convex run auth:createAdmin '{"email":"you@example.com","name":"Your Name","password":"a-strong-password"}'Dev and prod are separate databases with separate keys, so production needs the same two setup steps once:
npx convex deploy # push functions, schema and indexes
node scripts/setup-auth-keys.mjs --prod # signing keys for THIS deployment
npx convex run auth:createAdmin '{"email":"…","name":"…","password":"…"}' --prodWithout the keys, every sign-in on production fails with
Failed to execute 'atob' — a token signed for dev is meaningless to prod.
Point the project at this directory and override the build command so Convex deploys in the same step and hands the build its URL:
Build command: npx convex deploy --cmd 'next build' --cmd-url-env-var-name NEXT_PUBLIC_CONVEX_URL
Install: (leave as detected)
Output: (leave as detected)
Environment variables:
| Name | Value | Notes |
|---|---|---|
CONVEX_DEPLOY_KEY |
production deploy key | Convex dashboard → Settings → Deploy keys. This is what targets prod; keep it secret |
NEXT_PUBLIC_CONVEX_SITE_URL |
https://<deployment>.convex.site |
The .site domain, not .cloud. Nothing injects this — it backs the API endpoints shown in the UI |
NEXT_PUBLIC_SITE_URL |
https://<your-domain> |
The app's own public origin. Only used as metadataBase, but without it og:image resolves against localhost and link previews break |
NEXT_PUBLIC_CONVEX_URL is supplied by convex deploy --cmd, so do not set it
by hand. Do not set CONVEX_DEPLOYMENT on Vercel — that variable selects
your dev deployment and is for local use only.
Both NEXT_PUBLIC_* values are inlined at build time, so changing either one
needs a redeploy, not just a restart.
For preview deployments, add a preview deploy key instead and give each
preview its own signing keys with
node scripts/setup-auth-keys.mjs --preview-name <branch>.
There is no third-party auth provider. Convex is the identity provider:
auth.signInverifies a PBKDF2-SHA256 password hash and mints a 30-minute RS256 access token plus a 30-day refresh token (stored only as a SHA-256 hash).convex/http.tspublishes/.well-known/openid-configurationand/.well-known/jwks.json.convex/auth.config.tspoints Convex at its own site URL, soctx.auth.getUserIdentity()validates those tokens natively.- The browser holds the refresh token and
ConvexProviderWithAuthexchanges it for access tokens as they expire.
No function ever takes a user id as an argument for authorization; every check
derives the caller from ctx.auth.
| Scope | Role | Can do |
|---|---|---|
| Platform | admin |
Sees every account, workspace and agent token; can change roles, disable accounts, archive workspaces and revoke any MCP token |
| Workspace | owner |
Everything, including deleting the workspace |
| Workspace | admin |
Members, webhooks, API keys, forms, responses |
| Workspace | editor |
Builds forms, manages responses |
| Workspace | viewer |
Read-only |
| Route | Purpose |
|---|---|
/ |
Landing page |
/sign-in, /sign-up |
Authentication |
/app |
Redirects into your first workspace |
/app/w/{workspaceId} |
Workspace overview |
/app/w/{workspaceId}/forms |
Form list |
/app/w/{workspaceId}/forms/{formId} |
Builder — Build / Preview / Settings / Share |
/app/w/{workspaceId}/forms/{formId}/responses |
One form's responses + CSV export |
/app/w/{workspaceId}/responses |
Every response in the workspace |
/app/w/{workspaceId}/webhooks |
Endpoints and the delivery log |
/app/w/{workspaceId}/api |
API keys and endpoint reference |
/app/w/{workspaceId}/members |
Members and roles |
/app/w/{workspaceId}/settings |
Workspace settings |
/app/admin |
Platform admin console — stats and charts |
/app/admin/accounts |
Every account, with roles and enable/disable |
/app/admin/workspaces |
Every workspace, with archive-and-purge |
/app/admin/mcp |
Your platform MCP endpoint + every token on the platform |
/w/{workspaceSlug} |
Public — every published form in a workspace |
/f/{workspaceSlug}/{formSlug} |
Public — a single form |
Text · Long answer · Email · Phone · URL · Password · Number · Date · Time · Dropdown · Multi-select · Radio group · Checkbox group · Single checkbox · Switch · Slider · Star rating · One-time code · File upload · Hidden · Heading · Paragraph · Divider
Each field is full, half or third width and collapses to one column on a phone. Validation (required, min/max, length, regex, file size and type) runs in the renderer and again in Convex, so the HTTP API cannot bypass it.
Base URL is NEXT_PUBLIC_CONVEX_SITE_URL. CORS is open on all three endpoints.
# Published forms in a workspace
GET /api/v1/forms/{workspaceSlug}
# One form's steps, fields, options and validation rules
GET /api/v1/forms/{workspaceSlug}/{formSlug}
# Create a submission — values may be strings, numbers, booleans or arrays
POST /api/v1/submit/{workspaceSlug}/{formSlug}
{"full_name": "Ada Lovelace", "use_cases": ["onboarding"]}
-> 201 | 422 with {issues:[{key,message}]} | 404
# Read stored responses (requires a workspace API key)
GET /api/v1/submissions?form={formSlug}&limit=50
Authorization: Bearer mf_live_...API keys are shown once and stored only as a SHA-256 digest.
mcp/server.mjs exposes Magic Forms to AI agents over the Model Context
Protocol, so an agent can provision a company,
stand up a workspace, build and publish a form, manage members and read
responses without touching the UI.
It signs in as one ordinary Magic Forms account and holds that session, so the
tools are bounded by exactly the same authorization as the web app — nothing is
special-cased. A server signed in as a workspace editor cannot invite members;
one signed in as staff gets the platform console. Which tools exist follows from
who it signed in as: an account with the platform admin role also gets the
admin_* tools. That is a convenience for the agent, not the security boundary
— Convex re-checks the role on every call.
Create one under API keys → AI agents in any workspace, or — as platform
staff — under Admin console → MCP, which mints one belonging to no
workspace at all and carrying the admin_* tools. You get a URL:
https://forms.yourco.com/api/mcp/mf_mcp_...
Point any MCP client that speaks HTTP at it — nothing to install. The token is
shown once, acts as the account that created it, and is revoked from the same
screen. Authorization: Bearer <token> is honoured too, and is the better
choice where a client supports headers, since URLs end up in logs.
Redeeming a token mints the same short-lived access token a sign-in does, so
every call underneath still goes through requireWorkspaceAccess. The redeemed
credential is cached for up to a minute, which is also the longest a revoked
token can keep working.
Admin console → MCP lists every token on the platform — who it acts as, which workspace it was created in (or "Platform" for a staff one), when it last called, and whether it is still live — so standing agent access can be seen and cut off in one place.
The same tools ship as a stdio server for anyone who would rather not hand a
hosted endpoint their workspace. Put the account it should act as in
.env.local, which is git-ignored:
MAGIC_FORMS_EMAIL=agent@yourcompany.com
MAGIC_FORMS_PASSWORD=...
MAGIC_FORMS_APP_URL=http://localhost:3000 # optional, for the links it returns
Real environment variables win over the file, so a deployed agent needs no
.env.local at all. Check what that account can do:
node mcp/server.mjs --list.mcp.json in the repo root already registers it for Claude Code and anything
else reading that format. Clients wanting explicit config want
node mcp/server.mjs; the server finds .env.local from its own location, so
the working directory does not matter.
| Group | Tools |
|---|---|
| Workspaces | whoami, list_workspaces, create_workspace, get_workspace, update_workspace, archive_workspace |
| Members | list_members, add_member, update_member_role, remove_member |
| Forms | build_form, list_forms, get_form, create_form, update_form, set_form_status, duplicate_form, delete_form |
| Steps and fields | add_step, add_field, update_field, remove_field |
| Responses | list_responses, export_responses_csv |
| Integrations | list_webhooks, create_webhook, update_webhook, test_webhook, list_webhook_deliveries, remove_webhook, list_api_keys, create_api_key, revoke_api_key |
| Platform admin | admin_overview, admin_list_users, admin_list_workspaces, admin_create_company, admin_create_workspace_for, admin_set_user_role, admin_set_user_disabled, admin_delete_workspace |
build_form is the one to reach for first: it takes a whole form — steps,
fields, options, validation and settings — and returns a published URL in a
single call.
{
"workspaceId": "...",
"title": "Supplier onboarding",
"publish": true,
"steps": [
{
"title": "Your company",
"fields": [
{ "type": "text", "label": "Company name", "key": "company_name", "required": true, "width": "half" },
{ "type": "email", "label": "Contact email", "key": "contact_email", "required": true, "width": "half" },
{ "type": "select", "label": "Category", "key": "category",
"options": [{ "label": "Produce", "value": "produce" }] }
]
}
]
}admin_create_company is the other half: it creates the account, the workspace
it owns and the owner membership together, returning a temporary password once
when you do not supply one. It is backed by two Convex functions added for it —
admin:createCompany and admin:createWorkspaceFor — because the admin console
could previously only read and moderate, never provision.
Ten events: form.created, form.updated, form.published,
form.unpublished, form.deleted, form.viewed, form.step_completed,
submission.created, submission.updated, submission.deleted.
A webhook is scoped to the whole workspace, or to a single form. Deliveries are scheduled (never blocking the mutation), retried twice on a network error or 5xx with a 15s then 60s backoff, and logged with status code, response body and duration.
Every request carries:
x-magicforms-event submission.created
x-magicforms-delivery <delivery id>
x-magicforms-timestamp <unix seconds>
x-magicforms-signature sha256=<hmac>
where the HMAC is HMAC-SHA256(secret, timestamp + "." + rawBody).
import { createHmac, timingSafeEqual } from "node:crypto";
const expected =
"sha256=" +
createHmac("sha256", secret)
.update(req.headers["x-magicforms-timestamp"] + "." + rawBody)
.digest("hex");
timingSafeEqual(Buffer.from(expected), Buffer.from(req.headers["x-magicforms-signature"]));convex/
schema.ts tables, field types, webhook events
auth.ts sign up / in / out, token refresh, createAdmin
auth.config.ts points Convex at its own JWKS
http.ts JWKS + OIDC discovery, and the public REST API
workspaces.ts workspaces, members, roles, stats
forms.ts forms, steps, fields, ordering
submissions.ts responses, CSV export, read state
webhooks.ts endpoints, signed delivery with retries, delivery log
apiKeys.ts hashed keys
admin.ts platform console, and company provisioning
mcpTokens.ts tokens for the hosted MCP endpoint
publicForms.ts unauthenticated form rendering and submission
api.ts query/mutation backends for the REST endpoints
cleanup.ts batched cascade deletes, scheduled pruning
crons.ts six-hourly housekeeping
lib/
crypto.ts PBKDF2, SHA-256, HMAC, RS256 JWT signing (actions only)
authz.ts requireUser / requireWorkspaceAccess / requireFormAccess
events.ts webhook fan-out
validate.ts server-side submission validation
The tool definitions are shared: the stdio server and the hosted endpoint at
app/api/mcp/[token]/route.ts both serve the same mcp/tools/* modules, and
differ only in how they authenticate.
mcp/
server.mjs stdio MCP server: signs in, gates tools by role, serves them
convex.mjs the signed-in Convex session the stdio server runs on
api.mjs Convex function references, shared by both transports
schema.mjs JSON Schema builders and the shared enums
tools/
workspace.mjs whoami, workspaces, members
forms.mjs build_form, forms, steps, fields, responses
integrations.mjs webhooks and API keys
admin.mjs platform staff tools
Everything is composed from components/ui/* (shadcn) — no bespoke UI
primitives. The three app-level compositions are components/app-sidebar.tsx
(the workspace sidebar), components/field-control.tsx (renders one field of any
type) and components/form-renderer.tsx (the multi-step form used by both the
public page and the builder preview).
public/images/logo.png is the only hand-maintained image. Every favicon, app
icon and social card is derived from it:
npm run brand:assets # node scripts/generate-brand-assets.mjs
That writes app/favicon.ico (16/32/48 — the 16px slice is cropped to just the
document, because the full mark turns to mush at that size), app/icon.png,
app/apple-icon.png, app/opengraph-image.png, app/twitter-image.png, the
public/icons/* sizes that app/manifest.ts points at, and
public/images/logo-mark.png. Do not hand-edit those — replace logo.png and
re-run.
In the product, always render components/logo.tsx rather than an icon or a
raw <img>; it is the one place the mark's sizing and loading behaviour lives.
Next.js picks up favicon.ico, icon.png and apple-icon.png from app/ by
file convention, so metadata.icons is deliberately not set in
app/layout.tsx — setting it suppresses those conventions.