Form builders like Typeform feel simple from the outside: drag fields onto a canvas, share a link, collect responses. Underneath, they solve a hard problem. The schema is data, not code, and the frontend has to render arbitrary field combinations it has never seen before. It can be tempting to reach for a dedicated SaaS tool and accept the lock-in. You don't have to.
Strapi 5's Dynamic Zones give you a clean way to model a form as an ordered list of field components, the same content modeling approach that powers flexible page builders. Each field type is a component. A Dynamic Zone on the Form Content-Type lets a content editor arrange any combination of those components in any order, no code required. Next.js reads that structure and renders a matching form on the fly.
In brief:
- You'll model seven form field types as Strapi components and arrange them with a Dynamic Zone.
- A Next.js 16 dynamic renderer reads the Dynamic Zone response and builds the form UI from a component map.
- A custom Strapi controller validates submissions against the schema and stores them with the Document Service API.
- Public form pages get server-side rendering with
generateMetadata, plus an authenticated dashboard with CSV export.
What We're Building
The end product is a form builder where a non-developer creates forms in Strapi's Admin Panel by adding field components to a Dynamic Zone. Drop in a text input, an email field, a dropdown, and a checkbox group, reorder them with drag and drop, and save. There's no deploy step for the form itself: the schema lives as data.
The Next.js 16 frontend fetches that Dynamic Zone over Strapi's REST API and renders each component using a component map. A form.text-input object becomes a TextInputField, a form.dropdown becomes a DropdownField, and so on. The order of components in the zone defines the order of fields on the page. When a visitor submits, a Server Action posts the data to a custom Strapi controller that validates each value against its field's constraints before saving a Submission via the Document Service API.
Public form pages render server-side with SEO metadata, so each form has a shareable, indexable URL. Form owners get an authenticated dashboard listing submissions, filterable by date, with a CSV export endpoint. The architectural centerpiece is the Dynamic Zone, which turns a headless CMS into a no-code form builder.
What you'll learn:
- How to design Strapi components and a Dynamic Zone that model a form schema
- How to populate nested Dynamic Zone components with Strapi 5's
onfragment syntax - How to render forms dynamically from API data in Next.js 16
- How to validate and store submissions with a custom controller
- How to build public form pages and an authenticated submission dashboard
Prerequisites
Pin these versions so the code matches:
- Node.js v22 LTS (v20, v22, and v24 are all supported by Strapi 5; avoid odd-number releases like v23 and v25)
- Strapi 5.x (latest stable; verify with
npx create-strapi@latest) - Next.js 16.2.x with React 19.2
- Tailwind CSS v4.x
- PostgreSQL 17.x for production (SQLite works for local development)
You should be comfortable with the Next.js App Router, async/await, and basic REST concepts. A code editor like VS Code and a terminal round out the setup.
Setting Up the Strapi Backend
Step 1 — Install Strapi 5
Create the project from the CLI:
npx create-strapi@latest form-builder-api --skip-cloudThe installer asks a few interactive questions. Press Enter to accept defaults for local development, which gives you SQLite. For production, pass PostgreSQL connection details:
npx create-strapi@latest form-builder-api \
--dbclient postgres \
--dbhost 127.0.0.1 \
--dbport 5432 \
--dbname form_builder \
--dbusername strapiPostgreSQL users need SCHEMA permissions on the database. Without them, the Admin Panel returns 500 errors. Once the install finishes, start the dev server and create your admin account.
cd form-builder-api
npm run developStep 2 — Create Form Field Components
Each field type is a Strapi component under the form category. Components live in src/components/form/ and load automatically. You can create them in the Content-Type Builder or add the schema.json files directly. Here are all seven.
Text input at src/components/form/text-input.json:
{
"collectionName": "components_form_text_fields",
"info": {
"displayName": "Text Field",
"icon": "align-left"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"placeholder": { "type": "string" },
"required": { "type": "boolean", "default": false },
"maxLength": { "type": "integer" }
}
}Email input at src/components/form/email-input.json:
{
"collectionName": "components_form_email_inputs",
"info": {
"displayName": "Email Input",
"icon": "envelope"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"required": { "type": "boolean", "default": false }
}
}Text area at src/components/form/text-area.json:
{
"collectionName": "components_form_text_areas",
"info": {
"displayName": "Text Area",
"icon": "align-justify"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"rows": { "type": "integer", "default": 4 },
"required": { "type": "boolean", "default": false }
}
}Dropdown at src/components/form/dropdown.json. Options are stored as a JSON array:
{
"collectionName": "components_form_dropdowns",
"info": {
"displayName": "Dropdown",
"icon": "chevron-down"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"options": { "type": "json" },
"required": { "type": "boolean", "default": false }
}
}Checkbox group at src/components/form/checkbox-group.json:
{
"collectionName": "components_form_checkbox_groups",
"info": {
"displayName": "Checkbox Group",
"icon": "check-square"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"options": { "type": "json" }
}
}Number input at src/components/form/number-input.json:
{
"collectionName": "components_form_number_inputs",
"info": {
"displayName": "Number Input",
"icon": "hashtag"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"min": { "type": "integer" },
"max": { "type": "integer" },
"required": { "type": "boolean", "default": false }
}
}Date input at src/components/form/date-input.json:
{
"collectionName": "components_form_date_inputs",
"info": {
"displayName": "Date Input",
"icon": "calendar"
},
"options": {},
"attributes": {
"label": { "type": "string", "required": true },
"minDate": { "type": "date" },
"maxDate": { "type": "date" },
"required": { "type": "boolean", "default": false }
}
}The json type for options keeps the schema flexible, and you can store an array like ["United States", "United Kingdom", "Canada"] as a value in a JSON field. However, native dropdown (enumeration) fields in Strapi define their options as an enum array in the schema, not as a JSON value. The models documentation covers every available attribute type.
Step 3 — Define the Form and Submission Content-Types
The Form Content-Type holds the Dynamic Zone. Create it at src/api/form/content-types/form/schema.json:
{
"kind": "collectionType",
"collectionName": "forms",
"info": {
"singularName": "form",
"pluralName": "forms",
"displayName": "Form"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"title": { "type": "string", "required": true },
"slug": {
"type": "uid",
"targetField": "title",
"required": true
},
"description": { "type": "text" },
"successMessage": {
"type": "string",
"default": "Thank you for your response!"
},
"isActive": { "type": "boolean", "default": true },
"fields": {
"type": "dynamiczone",
"components": [
"form.text-input",
"form.email-input",
"form.text-area",
"form.dropdown",
"form.checkbox-group",
"form.number-input",
"form.date-input"
]
},
"owner": {
"type": "relation",
"relation": "manyToOne",
"target": "plugin::users-permissions.user"
},
"submissions": {
"type": "relation",
"relation": "oneToMany",
"target": "api::submission.submission",
"mappedBy": "form"
}
}
}The fields Dynamic Zone accepts all seven components. Its type is dynamiczone, and the components array lists the allowed UIDs in <category>.<component-name> format. The order an editor arranges them in the Content Manager becomes the form layout. The docs note that "the order of the fields and components inside a dynamic field is important."
The Submission Content-Type at src/api/submission/content-types/submission/schema.json:
{
"kind": "collectionType",
"collectionName": "submissions",
"info": {
"singularName": "submission",
"pluralName": "submissions",
"displayName": "Submission"
},
"options": {
"draftAndPublish": false
},
"attributes": {
"data": { "type": "json" },
"submittedAt": { "type": "datetime" },
"submitterEmail": { "type": "email" },
"form": {
"type": "relation",
"relation": "manyToOne",
"target": "api::form.form",
"inversedBy": "submissions"
}
}
}A submission stores responses as a json field mapping field labels to user input, plus the timestamp, an optional submitter email, and a relation back to the form.
Step 4 — Write the Submission Validation Controller
The interesting part is validation. A custom controller loads the form's Dynamic Zone schema, checks each submitted value against its field's constraints, then creates the Submission with the Document Service API. Create src/api/form/controllers/form.ts:
// src/api/form/controllers/form.ts
import { factories } from '@strapi/strapi';
function validateField(field: any, value: any): string | null {
const label = field.label;
if (field.__component === 'form.text-input') {
if (field.required && !value) return `${label} is required`;
if (field.maxLength && value && value.length > field.maxLength) {
return `${label} exceeds ${field.maxLength} characters`;
}
}
if (field.__component === 'form.email-input') {
if (field.required && !value) return `${label} is required`;
if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
return `${label} must be a valid email`;
}
}
if (field.__component === 'form.text-area') {
if (field.required && !value) return `${label} is required`;
}
if (field.__component === 'form.dropdown') {
if (field.required && !value) return `${label} is required`;
if (value && Array.isArray(field.options) && !field.options.includes(value)) {
return `${label} has an invalid selection`;
}
}
if (field.__component === 'form.number-input') {
if (field.required && (value === '' || value === null || value === undefined)) {
return `${label} is required`;
}
if (value !== '' && value !== null && value !== undefined) {
const num = Number(value);
if (Number.isNaN(num)) return `${label} must be a number`;
if (field.min != null && num < field.min) return `${label} must be at least ${field.min}`;
if (field.max != null && num > field.max) return `${label} must be at most ${field.max}`;
}
}
if (field.__component === 'form.date-input') {
if (field.required && !value) return `${label} is required`;
}
return null;
}
export default factories.createCoreController('api::form.form', ({ strapi }) => ({
async submitResponse(ctx) {
try {
const { documentId } = ctx.params;
const { data } = ctx.request.body;
const form = await strapi.documents('api::form.form').findOne({
documentId,
status: 'published',
populate: {
fields: {
on: {
'form.text-input': { fields: ['label', 'required', 'maxLength'] },
'form.email-input': { fields: ['label', 'required'] },
'form.text-area': { fields: ['label', 'required'] },
'form.dropdown': { fields: ['label', 'required', 'options'] },
'form.checkbox-group': { fields: ['label', 'options'] },
'form.number-input': { fields: ['label', 'required', 'min', 'max'] },
'form.date-input': { fields: ['label', 'required'] },
},
},
},
});
if (!form || !form.isActive) {
return ctx.notFound('Form not found or inactive');
}
const errors: Record<string, string> = {};
for (const field of form.fields as any[]) {
const value = data?.[field.label];
const error = validateField(field, value);
if (error) errors[field.label] = error;
}
if (Object.keys(errors).length > 0) {
return ctx.badRequest('Validation failed', { errors });
}
const submission = await strapi.documents('api::submission.submission').create({
data: {
data: data,
submittedAt: new Date().toISOString(),
submitterEmail: data?.Email ?? null,
form: { connect: [{ documentId }] },
},
});
ctx.body = { success: true, submissionId: submission.documentId };
} catch (err) {
ctx.badRequest('Submission failed', { error: (err as Error).message });
}
},
async exportCsv(ctx) {
const { documentId } = ctx.params;
const submissions = await strapi.documents('api::submission.submission').findMany({
filters: { form: { documentId: { $eq: documentId } } },
sort: 'submittedAt:desc',
});
if (!submissions.length) {
ctx.body = '';
ctx.response.type = 'text/csv';
return;
}
const columns = new Set<string>();
for (const s of submissions) {
Object.keys((s.data as Record<string, unknown>) ?? {}).forEach((k) => columns.add(k));
}
const headers = ['submittedAt', ...Array.from(columns)];
const escape = (val: unknown) => {
const str = val == null ? '' : String(val);
return `"${str.replace(/"/g, '""')}"`;
};
const rows = submissions.map((s) => {
const record = (s.data as Record<string, unknown>) ?? {};
const cells = headers.map((h) =>
h === 'submittedAt' ? escape(s.submittedAt) : escape(record[h])
);
return cells.join(',');
});
const csvString = [headers.map(escape).join(','), ...rows].join('\n');
ctx.response.attachment(`submissions-${documentId}.csv`);
ctx.response.type = 'text/csv';
ctx.body = csvString;
},
}));A few things worth flagging. The populate uses the on fragment syntax that Strapi 5 requires for Dynamic Zones over the REST API. The old v4 shared population strategy was removed, so each component gets its own field selection. The relation uses connect syntax. And the Document Service API replaces the deprecated Entity Service. Because the Document Service API bypasses the API layer's permission checks, sanitize any data you return to callers with strapi.contentAPI.sanitize.output() before sending it, one of several API security practices worth following. The submit endpoint here only returns a success flag and an ID, so there's nothing sensitive to leak, but any controller that echoes stored records back should sanitize first.
Per-component validation matters because the frontend constraints are advisory. A required attribute or a maxLength on an HTML input stops honest users, but anyone can open dev tools, strip those attributes, and post whatever they like straight to the endpoint. The controller is the only place that sees the authoritative schema, so it re-derives every rule from the form's own Dynamic Zone. Loading the schema per request also means validation always reflects the current form. If an editor adds a field or marks one required, the next submission validates against that change with no redeploy. The validateField helper switches on __component, so each field type checks exactly the constraints that apply to it.
Strapi's populate and filtering guide goes deep here, because the on fragment syntax is the part most v4 users stumble on. In Strapi 4 you could populate a Dynamic Zone with a single shared field selection that applied to every component. Strapi 5 removed that shared strategy. Each component in the zone declares its own field list inside the on object, keyed by UID. The upside is precision: you fetch only the attributes each field type needs, which keeps the payload small. The cost is that adding a new component type means adding a matching on entry here and in the page populate.
Register the custom route at src/api/form/routes/01-custom-form.ts. The 01- prefix loads it before the core routes:
// src/api/form/routes/01-custom-form.ts
export default {
routes: [
{
method: 'POST',
path: '/forms/:documentId/submit',
handler: 'api::form.form.submitResponse',
config: {
auth: false,
policies: [],
middlewares: [],
},
},
{
method: 'GET',
path: '/forms/:documentId/export-csv',
handler: 'api::form.form.exportCsv',
config: {
policies: [],
middlewares: [],
},
},
],
};The submit endpoint is public (auth: false) so anonymous visitors can respond. The export route stays authenticated.
Step 5 — Add the CSV Export Route
The exportCsv action is already included in the controller above. It downloads the rows of a view to a CSV file.
Before testing, enable the public find and findOne permissions for the Form Content-Type under Settings → Users & Permissions Plugin → Roles → Public, then save. These role-based permissions gate every request, and Strapi only returns published entries to public callers.
Building the Next.js 16 Frontend
Step 1 — Set Up the Next.js 16 Project
Scaffold the app. Tailwind CSS v4, TypeScript, and the App Router are defaults.
npx create-next-app@latest form-builder-webInstall qs to build the populate query string, which becomes unreadable as a raw string for nested Dynamic Zones:
npm install qs
npm install --save-dev @types/qsAdd environment variables in .env.local:
# .env.local
NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
STRAPI_API_TOKEN=your-read-only-tokenCreate a fetch helper at lib/strapi.ts:
// lib/strapi.ts
export const STRAPI_URL =
process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
export async function fetchStrapi(path: string, options: RequestInit = {}) {
const res = await fetch(`${STRAPI_URL}/api${path}`, {
...options,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${STRAPI_API_TOKEN}`,
...options.headers,
},
});
if (!res.ok) throw new Error(`Strapi fetch failed: ${res.status}`);
return res.json();
}Generate a read-only API token in the Admin Panel under Settings → Global settings → API Tokens. Read-only tokens can call only find and findOne, which is what the dashboard needs.
Step 2 — Build the Dynamic Form Renderer
This is the heart of the frontend. A component map ties each Strapi __component discriminator to a React field component. Start with the individual fields at app/forms/[slug]/fields.tsx:
'use client';
// app/forms/[slug]/fields.tsx
type FieldProps = { field: any };
export function TextInputField({ field }: FieldProps) {
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<input
type="text"
name={field.label}
placeholder={field.placeholder ?? ''}
required={field.required}
maxLength={field.maxLength ?? undefined}
className="w-full border rounded px-3 py-2"
/>
</div>
);
}
export function EmailInputField({ field }: FieldProps) {
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<input
type="email"
name={field.label}
required={field.required}
className="w-full border rounded px-3 py-2"
/>
</div>
);
}
export function TextAreaField({ field }: FieldProps) {
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<textarea
name={field.label}
rows={field.rows ?? 4}
required={field.required}
className="w-full border rounded px-3 py-2"
/>
</div>
);
}
export function DropdownField({ field }: FieldProps) {
const options: string[] = Array.isArray(field.options) ? field.options : [];
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<select
name={field.label}
required={field.required}
defaultValue=""
className="w-full border rounded px-3 py-2"
>
<option value="" disabled>
Select an option
</option>
{options.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
</div>
);
}
export function CheckboxGroupField({ field }: FieldProps) {
const options: string[] = Array.isArray(field.options) ? field.options : [];
return (
<fieldset className="mb-4">
<legend className="mb-1 font-medium">{field.label}</legend>
{options.map((opt) => (
<label key={opt} className="flex items-center gap-2 mb-1">
<input type="checkbox" name={field.label} value={opt} />
{opt}
</label>
))}
</fieldset>
);
}
export function NumberInputField({ field }: FieldProps) {
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<input
type="number"
name={field.label}
min={field.min ?? undefined}
max={field.max ?? undefined}
required={field.required}
className="w-full border rounded px-3 py-2"
/>
</div>
);
}
export function DateInputField({ field }: FieldProps) {
return (
<div className="mb-4">
<label className="block mb-1 font-medium">{field.label}</label>
<input
type="date"
name={field.label}
min={field.minDate ?? undefined}
max={field.maxDate ?? undefined}
required={field.required}
className="w-full border rounded px-3 py-2"
/>
</div>
);
}The renderer that maps UIDs to components. Create app/forms/[slug]/FormRenderer.tsx. It uses React 19's useActionState for submission state:
'use client';
// app/forms/[slug]/FormRenderer.tsx
import { useActionState } from 'react';
import { submitFormResponse } from './actions';
import {
TextInputField,
EmailInputField,
TextAreaField,
DropdownField,
CheckboxGroupField,
NumberInputField,
DateInputField,
} from './fields';
const componentMap: Record<string, React.ComponentType<{ field: any }>> = {
'form.text-input': TextInputField,
'form.email-input': EmailInputField,
'form.text-area': TextAreaField,
'form.dropdown': DropdownField,
'form.checkbox-group': CheckboxGroupField,
'form.number-input': NumberInputField,
'form.date-input': DateInputField,
};
const initialState = { success: false, message: '' };
export function FormRenderer({
formId,
fields,
successMessage,
}: {
formId: string;
fields: any[];
successMessage: string;
}) {
const [state, formAction, pending] = useActionState(
submitFormResponse,
initialState
);
if (state.success) {
return <p className="text-green-700 text-lg">{successMessage}</p>;
}
return (
<form action={formAction} className="max-w-lg">
<input type="hidden" name="formId" value={formId} />
{fields.map((field, index) => {
const FieldComponent = componentMap[field.__component];
if (!FieldComponent) return null;
return <FieldComponent key={field.id ?? index} field={field} />;
})}
{state.message && !state.success && (
<p aria-live="polite" className="text-red-600 mb-3">
{state.message}
</p>
)}
<button
type="submit"
disabled={pending}
className="bg-black text-white rounded px-4 py-2 disabled:opacity-50"
>
{pending ? 'Submitting...' : 'Submit'}
</button>
</form>
);
}Notice the lookup pattern: componentMap[field.__component] resolves the right component. If a field type isn't in the map, it returns null instead of crashing. That keeps the renderer resilient when you add new component types later.
Step 3 — Build Public Form Pages
The Server Action collects the FormData, builds a label-to-value map, and posts to the validation controller. Create app/forms/[slug]/actions.ts:
'use server';
// app/forms/[slug]/actions.ts
import { STRAPI_URL } from '@/lib/strapi';
type State = { success: boolean; message: string };
export async function submitFormResponse(
prevState: State,
formData: FormData
): Promise<State> {
const formId = formData.get('formId') as string;
const data: Record<string, unknown> = {};
for (const [key, value] of formData.entries()) {
if (key === 'formId') continue;
if (data[key] !== undefined) {
const existing = data[key];
data[key] = Array.isArray(existing)
? [...existing, value]
: [existing, value];
} else {
data[key] = value;
}
}
const res = await fetch(`${STRAPI_URL}/api/forms/${formId}/submit`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data }),
});
if (!res.ok) {
const body = await res.json().catch(() => null);
const message =
body?.error?.details?.errors
? Object.values(body.error.details.errors).join(' ')
: 'Submission failed. Please try again.';
return { success: false, message };
}
return { success: true, message: 'Submitted' };
}The prevState is the first argument because the action runs through useActionState. This signature trips up developers migrating from plain form handlers, so it's worth a second look. Expected errors come back as return values rather than thrown exceptions, which is the recommended pattern in Next.js.
The page itself at app/forms/[slug]/page.tsx. It fetches the form with the Dynamic Zone on fragment populate, renders the form, and exports generateMetadata for SEO. Remember that params is a Promise in Next.js 16 and must be awaited.
// app/forms/[slug]/page.tsx
import qs from 'qs';
import type { Metadata } from 'next';
import { STRAPI_URL } from '@/lib/strapi';
import { FormRenderer } from './FormRenderer';
const populateQuery = qs.stringify(
{
populate: {
fields: {
on: {
'form.text-input': {
fields: ['label', 'placeholder', 'required', 'maxLength'],
},
'form.email-input': {
fields: ['label', 'required'],
},
'form.text-area': {
fields: ['label', 'rows', 'required'],
},
'form.dropdown': {
fields: ['label', 'options', 'required'],
},
'form.checkbox-group': {
fields: ['label', 'options'],
},
'form.number-input': {
fields: ['label', 'min', 'max', 'required'],
},
'form.date-input': {
fields: ['label', 'minDate', 'maxDate', 'required'],
},
},
},
},
},
{ encodeValuesOnly: true }
);
async function getForm(slug: string) {
const res = await fetch(
`${STRAPI_URL}/api/forms?filters[slug][$eq]=${slug}&${populateQuery}`,
{ cache: 'no-store' }
);
const { data } = await res.json();
return data?.[0] ?? null;
}
export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}): Promise<Metadata> {
const { slug } = await params;
const form = await getForm(slug);
return {
title: form?.title ?? 'Form',
description: form?.description ?? 'Fill out this form',
};
}
export default async function FormPage({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const form = await getForm(slug);
if (!form) {
return <div className="p-8">Form not found</div>;
}
return (
<main className="max-w-lg mx-auto p-8">
<h1 className="text-2xl font-bold mb-2">{form.title}</h1>
{form.description && (
<p className="text-gray-600 mb-6">{form.description}</p>
)}
<FormRenderer
formId={form.documentId}
fields={form.fields}
successMessage={form.successMessage}
/>
</main>
);
}The fetch calls in generateMetadata and getForm are automatically memoized by Next.js, so fetching the same slug twice in one request doesn't hit Strapi twice. The cache: 'no-store' option keeps form data fresh, since an editor might update fields between requests. The flat response format means form attributes sit directly on the data object, with no data.attributes nesting, and the Dynamic Zone array lives at form.fields.
Step 4 — Build the Submission Dashboard
The dashboard lists submissions for a form, filters by date, shows the total count, and links to CSV export. This route should be protected, ideally with the same JWT authentication Strapi issues for logged-in users. In Next.js 16, route protection lives in proxy.ts, which replaces the old middleware.ts. Here's a minimal session check at the project root:
// proxy.ts
import { NextRequest, NextResponse } from 'next/server';
const protectedRoutes = ['/dashboard'];
export default function proxy(req: NextRequest) {
const path = req.nextUrl.pathname;
const isProtected = protectedRoutes.some((r) => path.startsWith(r));
const session = req.cookies.get('session')?.value;
if (isProtected && !session) {
return NextResponse.redirect(new URL('/login', req.nextUrl));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};Because the proxy verifies authentication before protected routes render, those pages never start rendering for unauthenticated visitors. The dashboard page at app/dashboard/[documentId]/page.tsx:
// app/dashboard/[documentId]/page.tsx
import qs from 'qs';
import { fetchStrapi, STRAPI_URL } from '@/lib/strapi';
async function getSubmissions(documentId: string, from?: string, to?: string) {
const filters: Record<string, unknown> = {
form: { documentId: { $eq: documentId } },
};
if (from || to) {
filters.submittedAt = {
...(from ? { $gte: from } : {}),
...(to ? { $lte: to } : {}),
};
}
const query = qs.stringify(
{ filters, sort: 'submittedAt:desc' },
{ encodeValuesOnly: true }
);
return fetchStrapi(`/submissions?${query}`);
}
export default async function DashboardPage({
params,
searchParams,
}: {
params: Promise<{ documentId: string }>;
searchParams: Promise<{ from?: string; to?: string }>;
}) {
const { documentId } = await params;
const { from, to } = await searchParams;
const { data: submissions } = await getSubmissions(documentId, from, to);
const columns = Array.from(
new Set(submissions.flatMap((s: any) => Object.keys(s.data ?? {})))
) as string[];
return (
<main className="max-w-5xl mx-auto p-8">
<div className="flex justify-between items-center mb-4">
<h1 className="text-2xl font-bold">
Submissions ({submissions.length})
</h1>
<a
href={`${STRAPI_URL}/api/forms/${documentId}/export-csv`}
className="bg-black text-white rounded px-4 py-2"
>
Export CSV
</a>
</div>
<form className="flex gap-3 mb-6 items-end">
<label className="flex flex-col text-sm">
From
<input type="date" name="from" defaultValue={from} className="border rounded px-2 py-1" />
</label>
<label className="flex flex-col text-sm">
To
<input type="date" name="to" defaultValue={to} className="border rounded px-2 py-1" />
</label>
<button type="submit" className="border rounded px-3 py-1">
Filter
</button>
</form>
<table className="w-full border-collapse text-sm">
<thead>
<tr className="border-b">
<th className="text-left p-2">Submitted</th>
{columns.map((col) => (
<th key={col} className="text-left p-2">{col}</th>
))}
</tr>
</thead>
<tbody>
{submissions.map((s: any) => (
<tr key={s.documentId} className="border-b">
<td className="p-2">
{new Date(s.submittedAt).toLocaleString()}
</td>
{columns.map((col) => (
<td key={col} className="p-2">
{Array.isArray(s.data?.[col])
? s.data[col].join(', ')
: s.data?.[col] ?? ''}
</td>
))}
</tr>
))}
</tbody>
</table>
</main>
);
}Date filtering uses Strapi's $gte and $lte filter operators on submittedAt, driven by the form's query parameters. The CSV export button links straight to the custom route from Step 5. The column set is derived from the union of all submission keys, so the table adapts to whatever fields the form contains.
The column derivation is worth a closer look because it makes the table schema-free. Each submission stores its answers as a flat object keyed by field label, and different forms produce different keys. Instead of hardcoding columns, the page flattens every submission's keys into a Set, which dedupes them, then spreads that set into a header row. A form with three fields and a form with nine fields both render correctly through the same component. When a value is an array, like the multiple selections from a checkbox group, the cell joins them with a comma so the row stays readable.
Putting It All Together
Time to see the whole thing work end to end. In Strapi's Admin Panel, create a new Form entry. Set the title to something like "Customer Feedback," which auto-generates the slug. Open the fields Dynamic Zone and add components in order:
- A Text Input with label "Name," marked required
- An Email Input with label "Email," marked required
- A Dropdown with label "How did you hear about us?" and an
optionsJSON array like["Search", "Social media", "A friend"] - A Checkbox Group with label "Interests" and options
["Product updates", "Tutorials", "Events"]
Reorder them by dragging if you like. The order you set here is the order they'll render. Each component you drop becomes an entry in the fields array with its own __component discriminator. When you save and publish, Strapi versions the entry and exposes only the published copy to public callers. The Next.js page fetches that published array, walks it in order, and hands each entry to the component map, so the layout you arranged in the Admin Panel is the layout visitors see. Save and publish.
Visit http://localhost:3000/forms/customer-feedback. The page fetches the Dynamic Zone, and the renderer builds each field from the component map. Fill it out, including an invalid email to confirm validation, and submit. A bad email returns the controller's error message inline. A valid submission swaps the form for the success message.
Open the dashboard at http://localhost:3000/dashboard/<form-documentId> to see the submission in the table with the total count. Set a date range and filter. Click Export CSV to download the responses as a flat file. The whole flow, from schema to storage, runs on data you arranged in the Admin Panel.
Next Steps
You have a working form builder, and there's room to grow it into a production-ready app. Deploy the backend to Strapi Cloud via the Cloud dashboard, and push the frontend to Vercel, which auto-detects Next.js and configures builds for you. Keep Next.js patched: CVE-2025-66478 affected both the 15.x and 16.x branches and requires upgrading to a fixed release such as 16.0.7.
Before you ship, add rate limiting to the public submit endpoint so a bot cannot flood your database, and consider a honeypot or CAPTCHA field for spam-heavy forms. Set the STRAPI_API_TOKEN as a server-only environment variable so it never reaches the browser, and scope it read-only as shown. Run the CSV export behind the same authentication as the dashboard, since submission data often contains personal information that should not be publicly downloadable.
From there, consider:
- Conditional logic: show field B only when field A equals a value, driven by an extra
jsonconfig on each component. - File upload field: add a
form.file-uploadcomponent backed by Strapi's Media Library. - Form analytics: collect and manage form submissions in Strapi, and use custom plugins or external analytics tools to track completion rate and per-field drop-off.
- Embeddable snippet: expose forms in an iframe for third-party sites.
- Submission webhooks: notify external services like Slack or email when a response arrives, or browse the Strapi Marketplace for email providers and related plugins.
Dig into the Document Service API and the Next.js docs for the details behind each of these. The Strapi integrations page covers connecting the rest of your stack.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.