# Self-host a form > Put an agent-fillable form in your own Next.js app with one shadcn command. It runs on your server and the answers go to your code (your database, your CRM). Nothing goes through Cube. This is for a developer with a Next.js (App Router) app. Agents get the form as Markdown and JSON Schema and post their answers as JSON; people get a page; both end in your `onSubmit`. It is open source and is installed like any shadcn component, so you own the code. ## Install ```sh npx shadcn add https://ui.usecube.io/r/agent-form.json ``` This copies the kit into `components/agent-form/` and the field kit into `components/fields/` (shadcn's `registryDependencies`). Add the field styles once, in your global CSS: `@import "../components/fields/fields.css";`. The page for people uses Tailwind and shadcn's theme variables. **Set shadcn up first** (`npx shadcn@latest init`, which writes `components.json`). Without a `components.json`, `shadcn add` asks to create one, and in a non-interactive shell (CI, an agent) it prints that question and installs nothing. The kit imports its own files with relative paths, so it does not need the `@/` alias to compile; your own files in this guide do. It compiles under `strict` with `lib` `dom` and `esnext` (no `dom.iterable` needed); `noUncheckedIndexedAccess` and `exactOptionalPropertyTypes` are not supported. ## 1. Define the form The fields are the same as in Cube's builder and the MCP tool `create_form` (the types and their settings are in [https://ui.usecube.io/new.md](https://ui.usecube.io/new.md)). Each answer's key is the label's words (`"Your name"` becomes `your_name`); give a `name` to choose it. ```ts // lib/forms.ts import { defineForm } from '@/components/agent-form' export const signup = defineForm({ title: 'Join the Acme waitlist', description: 'We email you when your spot opens.', about: 'Sign up for the Acme waitlist: a name, an email and what you would use it for.', // for agents fields: [ { type: 'short_text', label: 'Your name', name: 'name', required: true }, { type: 'email', label: 'Email', required: true }, { type: 'single_select', label: 'What would you use Acme for?', name: 'use', required: true, options: ['Work', 'Side project', 'Just looking'] }, { type: 'long_text', label: 'Anything we should know?' }, ], }) ``` `defineForm` checks the form when your server starts: a misspelled type or a field it can't read throws there, not in front of a visitor. ## 2. One route file ```ts // app/forms/[slug]/route.ts import { clientIp, formRoute, memoryRateLimit, safeFileName } from '@/components/agent-form' import { signup } from '@/lib/forms' export const { GET, POST } = formRoute({ forms: { signup }, onSubmit: async ({ form, answers, files, request }) => { const row = await db.insert('signups', { form: form.slug, ...answers }) // your database, your CRM, your Sheet for (const file of Object.values(files)) await bucket.put(`${row.id}/${safeFileName(file.name)}`, file) // file.name is the sender's: never use it as a path as it is return { id: row.id } // optional: a string or number id is added to the 201 reply; nothing else you return is sent back }, origin: 'https://acme.com', // recommended in production (else SITE_URL / NEXT_PUBLIC_SITE_URL, else the request's own address) rateLimit: memoryRateLimit({ max: 10, clientIp: clientIp.cloudflare }), // optional: your own limit, run before the body is read, see below page: (slug) => `/${slug}`, // optional: where a browser is sent }) ``` - `GET /forms/signup` answers an agent with the form as Markdown: `text/markdown` when its `Accept` names it, `text/plain` otherwise (the same rule as Cube's own forms). A request that names `text/html` and not `text/markdown` (a browser) is sent to `page` if you set it, because a `route.ts` and a `page.tsx` can't share an address; without `page`, everyone gets the Markdown. - `GET /forms/signup.md` is always the Markdown, and `GET /forms/signup.json` is the JSON Schema (2020-12) of the answers. - `POST /forms/signup` takes JSON `{"answers": {...}}`, `multipart/form-data` (the answers as JSON in the part `answers`, each file a part named after its question) or a plain urlencoded post. The answers are checked with the same messages as Cube's forms, then your `onSubmit` runs. `201` with `{"status": "completed", "id"?} (the id only if your onSubmit returned one)`; `400` with `{"errors": [{"field", "message"}]}` saying what to send instead; `429` with `Retry-After` from your `rateLimit`. - `onSubmit` gets the checked answers (text trimmed, `"yes"` as `true`, `"12"` as `12`, blank optional ones left out), `files` (a `File` per file question; its answer is `{name, type, size, sha256}`) and the `request` (for your own auth, address, logging). Throw `FormRejected("That email is already on the list.", "email")` to answer `400`; any other throw is a `500` whose text is not shown. - The links in the Markdown (where to POST) come from, in this order, the `origin` option; the `SITE_URL` or `NEXT_PUBLIC_SITE_URL` environment variable (`VERCEL_PROJECT_PRODUCTION_URL` on Vercel); the address of the request itself. A client's `X-Forwarded-Host` / `X-Forwarded-Proto` are **not** believed, because a shared cache that kept a page built from a forged header would send every agent's answers to the forger. Pass `trustForwardedHost: true` only behind a proxy of yours that sets them and removes the client's own. When the links come from the request, the page is sent with `Cache-Control: private, no-store`. - Without a `rateLimit` of your own, the route allows 600 requests a minute per form before the body is read (a cheap guard) and 60 a minute per form for answers that passed every check, all senders together; an invalid post does not use up the 60, so a mistaken agent cannot lock the form. Pass `rateLimit: false` for none. A `rateLimit` of yours is a function `({ request, slug }) => seconds to wait`, run before the body is read. `memoryRateLimit({ max, windowMs })` counts in the server's memory, per form by default (a route handler is not told the sender's address; at most 10,000 counters are kept). To count per address pass `clientIp`, and choose the preset of the platform you run on: `clientIp.vercel` (`x-real-ip`), `clientIp.cloudflare` (`cf-connecting-ip`), `clientIp.fly` (`fly-client-ip`), or `clientIp.fromRight(n)` (the n-th entry from the right of `X-Forwarded-For`; 1 for one proxy of yours that appends the address it saw). Never the first entry or a header your platform does not set: a client can write those. Behind several servers or on serverless, write your own against Redis, Upstash or your gateway. - Size: answers may be 1 MB; a form with file questions adds the sum of its files' limits (4/3 of that when the files go as base64 in JSON), never more. The server also holds at most 100 MB of request bodies in memory at once (`maxInflightBytes`): past it a post gets `503` with `Retry-After`. - A file is checked three ways and all three must agree: its first bytes (PDF, PNG, JPEG, GIF, WebP, Word `.doc` and `.docx`), its name's ending, and the type the sender names when it names one. `cv.pdf` sent as `text/html`, or an HTML file named `cv.pdf`, is refused with a message that says what is wrong. The answer holds the type the bytes say and a name made safe by `safeFileName` (the sender's own is `originalName`); `files[...]` is a `File` of that name and type. Still never build a path from a name without `safeFileName`. ## 3. A page for people ```tsx // app/signup/page.tsx import { AgentForm } from '@/components/agent-form/AgentForm' import { signup } from '@/lib/forms' export default function Page() { return } ``` It draws the form with the field kit and posts to the same route. A chatbot can hand its person a link with the answers in the fragment (`/signup#name=Jane&email=jane@example.com`): they are filled in and marked as the assistant's until the person edits them. ## Optional: MCP ```sh npx shadcn add https://ui.usecube.io/r/agent-form-mcp.json ``` ```ts // app/mcp/route.ts import { formMcp } from '@/components/agent-form-mcp/mcp' import { signup } from '@/lib/forms' import { onSubmit } from '@/lib/submit' // the same function the route uses export const { GET, POST, DELETE } = formMcp({ forms: { signup }, onSubmit }) ``` A stateless Streamable HTTP server ([mcp-handler](https://www.npmjs.com/package/mcp-handler), installed as a dependency) with `read_form` and `submit_form`. It hands the answers to the same `onSubmit`. Pass `mcp: "/mcp"` to `formRoute` and the Markdown tells agents about it. ## Optional: llms.txt ```ts // app/llms.txt/route.ts import { formsLlmsTxt } from '@/components/agent-form' import { forms } from '@/lib/forms' export const GET = (req: Request) => new Response(formsLlmsTxt(forms, new URL(req.url).origin, { title: 'Acme' }), { headers: { 'Content-Type': 'text/plain; charset=utf-8' } }) ``` It lists each form at its `.md` address. ## What it does not do - It stores nothing: your `onSubmit` is the only place an answer goes. - It runs on the Node.js runtime only (it uses `Buffer`), not the Edge runtime: leave `export const runtime` at its default in the route files. - It has no sign-in: anyone with the address can fill the form, like a public contact form. Check a session or a token in `rateLimit` or `onSubmit` if the form is private. **Cookie-based auth is cross-site forgeable**: a browser form on another site can post to this route (a urlencoded post needs no preflight). If `onSubmit` acts on a cookie session, check `Origin` or `Sec-Fetch-Site` on `request` there. - A link (`{"url"}`) as a file is never downloaded.