Open Form

Next.js

Mount the Open Form API in a Next.js app and render forms in your own pages.

@open-form-build/core/next runs the same submission handler inside a Next.js App Router app. Forms render in your own pages with <FormHost>, and validation still comes from the same form files on both sides.

Keep forms/ (and optionally open-form.config.ts) at the root of the Next.js app. A form's look travels with it in forms/<id>/theme/:

my-app/
├── app/
│   ├── api/[...openform]/route.ts
│   └── rsvp/
│       ├── page.tsx
│       └── rsvp-form.tsx
├── forms/
│   └── rsvp/
│       ├── index.tsx
│       └── theme/           # the form's own theme, if it has one
├── open-form.config.ts
└── package.json

Install

npm install @open-form-build/core

Mount the API

app/api/[...openform]/route.ts
import { createOpenFormRoute } from '@open-form-build/core/next';

export const { GET, POST, PUT, DELETE, OPTIONS } = createOpenFormRoute();

The route reads each request, so Next.js always runs it on demand. Don't add export const dynamic: Next.js 16 rejects it when Cache Components (cacheComponents: true) is on.

Render the form

<FormHost> fetches the form's availability and timing token from the API, then renders it. Import the runtime styles once.

app/rsvp/rsvp-form.tsx
'use client';
import { FormHost } from '@open-form-build/core';
import '@open-form-build/core/styles.css';
import Rsvp, { config } from '../../forms/rsvp';

export function RsvpForm() {
  return <FormHost id="rsvp" form={Rsvp} config={config} />;
}

Build the server bundle

package.json
{
  "scripts": {
    "dev": "open-form build --server-only --watch & next dev",
    "build": "open-form build --server-only && next build"
  }
}

& runs both at once in macOS and Linux shells. Windows cmd waits for the first command to exit, so there, run open-form build --server-only --watch and next dev in two terminals.

Ship the bundle with the route

The route loads dist/server/entry.js by path at runtime, so Next.js's file tracing can't see it. With output: 'standalone' or on Vercel, include it by hand:

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  outputFileTracingIncludes: {
    '/api/*': ['./dist/server/**/*'],
  },
};

export default nextConfig;

The key matches the route's path, /api/[...openform], and the value is the bundle folder. If you mount the route somewhere else or pass outDir, change them to match.

Why a server bundle

App Router route handlers run in the React Server Components layer, which can't render a form to learn its fields. So the route reads dist/server/entry.js, a single file that open-form build --server-only writes with its own copy of React. The browser and the server still come from the same form source.

Outside production the route checks the bundle on each request and reloads it after a rebuild, so --watch plus next dev picks up edits to forms and to open-form.config.ts. Restart the watcher after adding a form folder.

Options

Prop

Type

If you mount the route somewhere else, set basePath to match and pass the same path to <FormHost api>.

Like open-form start, the route reads OPEN_FORM_ADMIN_PASSWORD, OPEN_FORM_SECRET, OPEN_FORM_DB and OPEN_FORM_WEBHOOK_SECRET. It ignores OPEN_FORM_TRUST_PROXY; pass trustProxy instead. It also ignores OPEN_FORM_SECURE_COOKIES and has no option in its place: cookies are marked Secure when the request URL or the page's Origin is https://.

Rate limits and Studio sign-in attempts are counted per client IP, read from the right of X-Forwarded-For. Vercel sets that header itself. When you self-host, keep next start behind a reverse proxy that appends the client address (nginx: proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for). Exposed directly, Next.js keeps whatever X-Forwarded-For the client sends, so anyone can pick their own IP and get around both limits.

<FormHost> props

Prop

Type

Storage, admin and Vercel

  • Storage. Responses go to SQLite in .open-form/ by default, which needs a persistent disk. On a serverless platform pass storage with an adapter that persists; see Storage. Keep .open-form/ and dist/ out of git.

  • Secret. Without OPEN_FORM_SECRET, the route generates one into .open-form/secret. Serverless functions can't write there, so on Vercel and other read-only hosts set OPEN_FORM_SECRET (or pass secret). It must be at least 16 characters. Generate one and keep it the same across deploys, or signed tokens and Studio sessions stop working:

    node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
  • Admin API. The endpoints for responses, export and delete exist only when OPEN_FORM_ADMIN_PASSWORD is set or you pass adminAuth. The password must be at least 16 characters; a passphrase of a few words works. To reuse your app's sign-in, pass an authorize check.

  • Setup errors. The route sets up the API on its first request. A secret or admin password under 16 characters, a webhook secret that isn't base64, or an invalid webhooks or embed.origins entry makes that fail, and then every request fails with the error, public form routes included. Check the Next.js server logs.

  • Vercel. Pass waitUntil: after (from next/server) so webhooks finish after the response is sent. Retries that come minutes later outlast the function and are lost.

app/api/[...openform]/route.ts
import { after } from 'next/server';
import { createOpenFormRoute } from '@open-form-build/core/next';
import { auth, isAdmin } from '@/lib/auth'; // your app's own sign-in

export const { GET, POST, PUT, DELETE, OPTIONS } = createOpenFormRoute({
  adminAuth: { authorize: async () => isAdmin(await auth()) },
  waitUntil: after,
});

What's next

On this page