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:
generateMetadata, plus an authenticated dashboard with CSV export.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:
on fragment syntaxPin these versions so the code matches:
npx create-strapi@latest)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.
Create the project from the CLI:
1npx 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:
1npx create-strapi@latest form-builder-api \
2 --dbclient postgres \
3 --dbhost 127.0.0.1 \
4 --dbport 5432 \
5 --dbname form_builder \
6 --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.
1cd form-builder-api
2npm run developEach 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:
1{
2 "collectionName": "components_form_text_fields",
3 "info": {
4 "displayName": "Text Field",
5 "icon": "align-left"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "placeholder": { "type": "string" },
11 "required": { "type": "boolean", "default": false },
12 "maxLength": { "type": "integer" }
13 }
14}Email input at src/components/form/email-input.json:
1{
2 "collectionName": "components_form_email_inputs",
3 "info": {
4 "displayName": "Email Input",
5 "icon": "envelope"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "required": { "type": "boolean", "default": false }
11 }
12}Text area at src/components/form/text-area.json:
1{
2 "collectionName": "components_form_text_areas",
3 "info": {
4 "displayName": "Text Area",
5 "icon": "align-justify"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "rows": { "type": "integer", "default": 4 },
11 "required": { "type": "boolean", "default": false }
12 }
13}Dropdown at src/components/form/dropdown.json. Options are stored as a JSON array:
1{
2 "collectionName": "components_form_dropdowns",
3 "info": {
4 "displayName": "Dropdown",
5 "icon": "chevron-down"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "options": { "type": "json" },
11 "required": { "type": "boolean", "default": false }
12 }
13}Checkbox group at src/components/form/checkbox-group.json:
1{
2 "collectionName": "components_form_checkbox_groups",
3 "info": {
4 "displayName": "Checkbox Group",
5 "icon": "check-square"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "options": { "type": "json" }
11 }
12}Number input at src/components/form/number-input.json:
1{
2 "collectionName": "components_form_number_inputs",
3 "info": {
4 "displayName": "Number Input",
5 "icon": "hashtag"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "min": { "type": "integer" },
11 "max": { "type": "integer" },
12 "required": { "type": "boolean", "default": false }
13 }
14}Date input at src/components/form/date-input.json:
1{
2 "collectionName": "components_form_date_inputs",
3 "info": {
4 "displayName": "Date Input",
5 "icon": "calendar"
6 },
7 "options": {},
8 "attributes": {
9 "label": { "type": "string", "required": true },
10 "minDate": { "type": "date" },
11 "maxDate": { "type": "date" },
12 "required": { "type": "boolean", "default": false }
13 }
14}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.
The Form Content-Type holds the Dynamic Zone. Create it at src/api/form/content-types/form/schema.json:
1{
2 "kind": "collectionType",
3 "collectionName": "forms",
4 "info": {
5 "singularName": "form",
6 "pluralName": "forms",
7 "displayName": "Form"
8 },
9 "options": {
10 "draftAndPublish": true
11 },
12 "attributes": {
13 "title": { "type": "string", "required": true },
14 "slug": {
15 "type": "uid",
16 "targetField": "title",
17 "required": true
18 },
19 "description": { "type": "text" },
20 "successMessage": {
21 "type": "string",
22 "default": "Thank you for your response!"
23 },
24 "isActive": { "type": "boolean", "default": true },
25 "fields": {
26 "type": "dynamiczone",
27 "components": [
28 "form.text-input",
29 "form.email-input",
30 "form.text-area",
31 "form.dropdown",
32 "form.checkbox-group",
33 "form.number-input",
34 "form.date-input"
35 ]
36 },
37 "owner": {
38 "type": "relation",
39 "relation": "manyToOne",
40 "target": "plugin::users-permissions.user"
41 },
42 "submissions": {
43 "type": "relation",
44 "relation": "oneToMany",
45 "target": "api::submission.submission",
46 "mappedBy": "form"
47 }
48 }
49}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:
1{
2 "kind": "collectionType",
3 "collectionName": "submissions",
4 "info": {
5 "singularName": "submission",
6 "pluralName": "submissions",
7 "displayName": "Submission"
8 },
9 "options": {
10 "draftAndPublish": false
11 },
12 "attributes": {
13 "data": { "type": "json" },
14 "submittedAt": { "type": "datetime" },
15 "submitterEmail": { "type": "email" },
16 "form": {
17 "type": "relation",
18 "relation": "manyToOne",
19 "target": "api::form.form",
20 "inversedBy": "submissions"
21 }
22 }
23}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.
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:
1// src/api/form/controllers/form.ts
2import { factories } from '@strapi/strapi';
3
4function validateField(field: any, value: any): string | null {
5 const label = field.label;
6
7 if (field.__component === 'form.text-input') {
8 if (field.required && !value) return `${label} is required`;
9 if (field.maxLength && value && value.length > field.maxLength) {
10 return `${label} exceeds ${field.maxLength} characters`;
11 }
12 }
13
14 if (field.__component === 'form.email-input') {
15 if (field.required && !value) return `${label} is required`;
16 if (value && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) {
17 return `${label} must be a valid email`;
18 }
19 }
20
21 if (field.__component === 'form.text-area') {
22 if (field.required && !value) return `${label} is required`;
23 }
24
25 if (field.__component === 'form.dropdown') {
26 if (field.required && !value) return `${label} is required`;
27 if (value && Array.isArray(field.options) && !field.options.includes(value)) {
28 return `${label} has an invalid selection`;
29 }
30 }
31
32 if (field.__component === 'form.number-input') {
33 if (field.required && (value === '' || value === null || value === undefined)) {
34 return `${label} is required`;
35 }
36 if (value !== '' && value !== null && value !== undefined) {
37 const num = Number(value);
38 if (Number.isNaN(num)) return `${label} must be a number`;
39 if (field.min != null && num < field.min) return `${label} must be at least ${field.min}`;
40 if (field.max != null && num > field.max) return `${label} must be at most ${field.max}`;
41 }
42 }
43
44 if (field.__component === 'form.date-input') {
45 if (field.required && !value) return `${label} is required`;
46 }
47
48 return null;
49}
50
51export default factories.createCoreController('api::form.form', ({ strapi }) => ({
52 async submitResponse(ctx) {
53 try {
54 const { documentId } = ctx.params;
55 const { data } = ctx.request.body;
56
57 const form = await strapi.documents('api::form.form').findOne({
58 documentId,
59 status: 'published',
60 populate: {
61 fields: {
62 on: {
63 'form.text-input': { fields: ['label', 'required', 'maxLength'] },
64 'form.email-input': { fields: ['label', 'required'] },
65 'form.text-area': { fields: ['label', 'required'] },
66 'form.dropdown': { fields: ['label', 'required', 'options'] },
67 'form.checkbox-group': { fields: ['label', 'options'] },
68 'form.number-input': { fields: ['label', 'required', 'min', 'max'] },
69 'form.date-input': { fields: ['label', 'required'] },
70 },
71 },
72 },
73 });
74
75 if (!form || !form.isActive) {
76 return ctx.notFound('Form not found or inactive');
77 }
78
79 const errors: Record<string, string> = {};
80 for (const field of form.fields as any[]) {
81 const value = data?.[field.label];
82 const error = validateField(field, value);
83 if (error) errors[field.label] = error;
84 }
85
86 if (Object.keys(errors).length > 0) {
87 return ctx.badRequest('Validation failed', { errors });
88 }
89
90 const submission = await strapi.documents('api::submission.submission').create({
91 data: {
92 data: data,
93 submittedAt: new Date().toISOString(),
94 submitterEmail: data?.Email ?? null,
95 form: { connect: [{ documentId }] },
96 },
97 });
98
99 ctx.body = { success: true, submissionId: submission.documentId };
100 } catch (err) {
101 ctx.badRequest('Submission failed', { error: (err as Error).message });
102 }
103 },
104
105 async exportCsv(ctx) {
106 const { documentId } = ctx.params;
107
108 const submissions = await strapi.documents('api::submission.submission').findMany({
109 filters: { form: { documentId: { $eq: documentId } } },
110 sort: 'submittedAt:desc',
111 });
112
113 if (!submissions.length) {
114 ctx.body = '';
115 ctx.response.type = 'text/csv';
116 return;
117 }
118
119 const columns = new Set<string>();
120 for (const s of submissions) {
121 Object.keys((s.data as Record<string, unknown>) ?? {}).forEach((k) => columns.add(k));
122 }
123 const headers = ['submittedAt', ...Array.from(columns)];
124
125 const escape = (val: unknown) => {
126 const str = val == null ? '' : String(val);
127 return `"${str.replace(/"/g, '""')}"`;
128 };
129
130 const rows = submissions.map((s) => {
131 const record = (s.data as Record<string, unknown>) ?? {};
132 const cells = headers.map((h) =>
133 h === 'submittedAt' ? escape(s.submittedAt) : escape(record[h])
134 );
135 return cells.join(',');
136 });
137
138 const csvString = [headers.map(escape).join(','), ...rows].join('\n');
139
140 ctx.response.attachment(`submissions-${documentId}.csv`);
141 ctx.response.type = 'text/csv';
142 ctx.body = csvString;
143 },
144}));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:
1// src/api/form/routes/01-custom-form.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/forms/:documentId/submit',
7 handler: 'api::form.form.submitResponse',
8 config: {
9 auth: false,
10 policies: [],
11 middlewares: [],
12 },
13 },
14 {
15 method: 'GET',
16 path: '/forms/:documentId/export-csv',
17 handler: 'api::form.form.exportCsv',
18 config: {
19 policies: [],
20 middlewares: [],
21 },
22 },
23 ],
24};The submit endpoint is public (auth: false) so anonymous visitors can respond. The export route stays authenticated.
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.
Scaffold the app. Tailwind CSS v4, TypeScript, and the App Router are defaults.
1npx 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:
1npm install qs
2npm install --save-dev @types/qsAdd environment variables in .env.local:
1# .env.local
2NEXT_PUBLIC_STRAPI_URL=http://localhost:1337
3STRAPI_API_TOKEN=your-read-only-tokenCreate a fetch helper at lib/strapi.ts:
1// lib/strapi.ts
2export const STRAPI_URL =
3 process.env.NEXT_PUBLIC_STRAPI_URL || 'http://localhost:1337';
4
5const STRAPI_API_TOKEN = process.env.STRAPI_API_TOKEN;
6
7export async function fetchStrapi(path: string, options: RequestInit = {}) {
8 const res = await fetch(`${STRAPI_URL}/api${path}`, {
9 ...options,
10 headers: {
11 'Content-Type': 'application/json',
12 Authorization: `Bearer ${STRAPI_API_TOKEN}`,
13 ...options.headers,
14 },
15 });
16 if (!res.ok) throw new Error(`Strapi fetch failed: ${res.status}`);
17 return res.json();
18}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.
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:
1'use client';
2// app/forms/[slug]/fields.tsx
3
4type FieldProps = { field: any };
5
6export function TextInputField({ field }: FieldProps) {
7 return (
8 <div className="mb-4">
9 <label className="block mb-1 font-medium">{field.label}</label>
10 <input
11 type="text"
12 name={field.label}
13 placeholder={field.placeholder ?? ''}
14 required={field.required}
15 maxLength={field.maxLength ?? undefined}
16 className="w-full border rounded px-3 py-2"
17 />
18 </div>
19 );
20}
21
22export function EmailInputField({ field }: FieldProps) {
23 return (
24 <div className="mb-4">
25 <label className="block mb-1 font-medium">{field.label}</label>
26 <input
27 type="email"
28 name={field.label}
29 required={field.required}
30 className="w-full border rounded px-3 py-2"
31 />
32 </div>
33 );
34}
35
36export function TextAreaField({ field }: FieldProps) {
37 return (
38 <div className="mb-4">
39 <label className="block mb-1 font-medium">{field.label}</label>
40 <textarea
41 name={field.label}
42 rows={field.rows ?? 4}
43 required={field.required}
44 className="w-full border rounded px-3 py-2"
45 />
46 </div>
47 );
48}
49
50export function DropdownField({ field }: FieldProps) {
51 const options: string[] = Array.isArray(field.options) ? field.options : [];
52 return (
53 <div className="mb-4">
54 <label className="block mb-1 font-medium">{field.label}</label>
55 <select
56 name={field.label}
57 required={field.required}
58 defaultValue=""
59 className="w-full border rounded px-3 py-2"
60 >
61 <option value="" disabled>
62 Select an option
63 </option>
64 {options.map((opt) => (
65 <option key={opt} value={opt}>
66 {opt}
67 </option>
68 ))}
69 </select>
70 </div>
71 );
72}
73
74export function CheckboxGroupField({ field }: FieldProps) {
75 const options: string[] = Array.isArray(field.options) ? field.options : [];
76 return (
77 <fieldset className="mb-4">
78 <legend className="mb-1 font-medium">{field.label}</legend>
79 {options.map((opt) => (
80 <label key={opt} className="flex items-center gap-2 mb-1">
81 <input type="checkbox" name={field.label} value={opt} />
82 {opt}
83 </label>
84 ))}
85 </fieldset>
86 );
87}
88
89export function NumberInputField({ field }: FieldProps) {
90 return (
91 <div className="mb-4">
92 <label className="block mb-1 font-medium">{field.label}</label>
93 <input
94 type="number"
95 name={field.label}
96 min={field.min ?? undefined}
97 max={field.max ?? undefined}
98 required={field.required}
99 className="w-full border rounded px-3 py-2"
100 />
101 </div>
102 );
103}
104
105export function DateInputField({ field }: FieldProps) {
106 return (
107 <div className="mb-4">
108 <label className="block mb-1 font-medium">{field.label}</label>
109 <input
110 type="date"
111 name={field.label}
112 min={field.minDate ?? undefined}
113 max={field.maxDate ?? undefined}
114 required={field.required}
115 className="w-full border rounded px-3 py-2"
116 />
117 </div>
118 );
119}The renderer that maps UIDs to components. Create app/forms/[slug]/FormRenderer.tsx. It uses React 19's useActionState for submission state:
1'use client';
2// app/forms/[slug]/FormRenderer.tsx
3
4import { useActionState } from 'react';
5import { submitFormResponse } from './actions';
6import {
7 TextInputField,
8 EmailInputField,
9 TextAreaField,
10 DropdownField,
11 CheckboxGroupField,
12 NumberInputField,
13 DateInputField,
14} from './fields';
15
16const componentMap: Record<string, React.ComponentType<{ field: any }>> = {
17 'form.text-input': TextInputField,
18 'form.email-input': EmailInputField,
19 'form.text-area': TextAreaField,
20 'form.dropdown': DropdownField,
21 'form.checkbox-group': CheckboxGroupField,
22 'form.number-input': NumberInputField,
23 'form.date-input': DateInputField,
24};
25
26const initialState = { success: false, message: '' };
27
28export function FormRenderer({
29 formId,
30 fields,
31 successMessage,
32}: {
33 formId: string;
34 fields: any[];
35 successMessage: string;
36}) {
37 const [state, formAction, pending] = useActionState(
38 submitFormResponse,
39 initialState
40 );
41
42 if (state.success) {
43 return <p className="text-green-700 text-lg">{successMessage}</p>;
44 }
45
46 return (
47 <form action={formAction} className="max-w-lg">
48 <input type="hidden" name="formId" value={formId} />
49 {fields.map((field, index) => {
50 const FieldComponent = componentMap[field.__component];
51 if (!FieldComponent) return null;
52 return <FieldComponent key={field.id ?? index} field={field} />;
53 })}
54 {state.message && !state.success && (
55 <p aria-live="polite" className="text-red-600 mb-3">
56 {state.message}
57 </p>
58 )}
59 <button
60 type="submit"
61 disabled={pending}
62 className="bg-black text-white rounded px-4 py-2 disabled:opacity-50"
63 >
64 {pending ? 'Submitting...' : 'Submit'}
65 </button>
66 </form>
67 );
68}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.
The Server Action collects the FormData, builds a label-to-value map, and posts to the validation controller. Create app/forms/[slug]/actions.ts:
1'use server';
2// app/forms/[slug]/actions.ts
3
4import { STRAPI_URL } from '@/lib/strapi';
5
6type State = { success: boolean; message: string };
7
8export async function submitFormResponse(
9 prevState: State,
10 formData: FormData
11): Promise<State> {
12 const formId = formData.get('formId') as string;
13
14 const data: Record<string, unknown> = {};
15 for (const [key, value] of formData.entries()) {
16 if (key === 'formId') continue;
17 if (data[key] !== undefined) {
18 const existing = data[key];
19 data[key] = Array.isArray(existing)
20 ? [...existing, value]
21 : [existing, value];
22 } else {
23 data[key] = value;
24 }
25 }
26
27 const res = await fetch(`${STRAPI_URL}/api/forms/${formId}/submit`, {
28 method: 'POST',
29 headers: { 'Content-Type': 'application/json' },
30 body: JSON.stringify({ data }),
31 });
32
33 if (!res.ok) {
34 const body = await res.json().catch(() => null);
35 const message =
36 body?.error?.details?.errors
37 ? Object.values(body.error.details.errors).join(' ')
38 : 'Submission failed. Please try again.';
39 return { success: false, message };
40 }
41
42 return { success: true, message: 'Submitted' };
43}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.
1// app/forms/[slug]/page.tsx
2import qs from 'qs';
3import type { Metadata } from 'next';
4import { STRAPI_URL } from '@/lib/strapi';
5import { FormRenderer } from './FormRenderer';
6
7const populateQuery = qs.stringify(
8 {
9 populate: {
10 fields: {
11 on: {
12 'form.text-input': {
13 fields: ['label', 'placeholder', 'required', 'maxLength'],
14 },
15 'form.email-input': {
16 fields: ['label', 'required'],
17 },
18 'form.text-area': {
19 fields: ['label', 'rows', 'required'],
20 },
21 'form.dropdown': {
22 fields: ['label', 'options', 'required'],
23 },
24 'form.checkbox-group': {
25 fields: ['label', 'options'],
26 },
27 'form.number-input': {
28 fields: ['label', 'min', 'max', 'required'],
29 },
30 'form.date-input': {
31 fields: ['label', 'minDate', 'maxDate', 'required'],
32 },
33 },
34 },
35 },
36 },
37 { encodeValuesOnly: true }
38);
39
40async function getForm(slug: string) {
41 const res = await fetch(
42 `${STRAPI_URL}/api/forms?filters[slug][$eq]=${slug}&${populateQuery}`,
43 { cache: 'no-store' }
44 );
45 const { data } = await res.json();
46 return data?.[0] ?? null;
47}
48
49export async function generateMetadata({
50 params,
51}: {
52 params: Promise<{ slug: string }>;
53}): Promise<Metadata> {
54 const { slug } = await params;
55 const form = await getForm(slug);
56 return {
57 title: form?.title ?? 'Form',
58 description: form?.description ?? 'Fill out this form',
59 };
60}
61
62export default async function FormPage({
63 params,
64}: {
65 params: Promise<{ slug: string }>;
66}) {
67 const { slug } = await params;
68 const form = await getForm(slug);
69
70 if (!form) {
71 return <div className="p-8">Form not found</div>;
72 }
73
74 return (
75 <main className="max-w-lg mx-auto p-8">
76 <h1 className="text-2xl font-bold mb-2">{form.title}</h1>
77 {form.description && (
78 <p className="text-gray-600 mb-6">{form.description}</p>
79 )}
80 <FormRenderer
81 formId={form.documentId}
82 fields={form.fields}
83 successMessage={form.successMessage}
84 />
85 </main>
86 );
87}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.
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:
1// proxy.ts
2import { NextRequest, NextResponse } from 'next/server';
3
4const protectedRoutes = ['/dashboard'];
5
6export default function proxy(req: NextRequest) {
7 const path = req.nextUrl.pathname;
8 const isProtected = protectedRoutes.some((r) => path.startsWith(r));
9 const session = req.cookies.get('session')?.value;
10
11 if (isProtected && !session) {
12 return NextResponse.redirect(new URL('/login', req.nextUrl));
13 }
14
15 return NextResponse.next();
16}
17
18export const config = {
19 matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
20};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:
1// app/dashboard/[documentId]/page.tsx
2import qs from 'qs';
3import { fetchStrapi, STRAPI_URL } from '@/lib/strapi';
4
5async function getSubmissions(documentId: string, from?: string, to?: string) {
6 const filters: Record<string, unknown> = {
7 form: { documentId: { $eq: documentId } },
8 };
9 if (from || to) {
10 filters.submittedAt = {
11 ...(from ? { $gte: from } : {}),
12 ...(to ? { $lte: to } : {}),
13 };
14 }
15 const query = qs.stringify(
16 { filters, sort: 'submittedAt:desc' },
17 { encodeValuesOnly: true }
18 );
19 return fetchStrapi(`/submissions?${query}`);
20}
21
22export default async function DashboardPage({
23 params,
24 searchParams,
25}: {
26 params: Promise<{ documentId: string }>;
27 searchParams: Promise<{ from?: string; to?: string }>;
28}) {
29 const { documentId } = await params;
30 const { from, to } = await searchParams;
31 const { data: submissions } = await getSubmissions(documentId, from, to);
32
33 const columns = Array.from(
34 new Set(submissions.flatMap((s: any) => Object.keys(s.data ?? {})))
35 ) as string[];
36
37 return (
38 <main className="max-w-5xl mx-auto p-8">
39 <div className="flex justify-between items-center mb-4">
40 <h1 className="text-2xl font-bold">
41 Submissions ({submissions.length})
42 </h1>
43 <a
44 href={`${STRAPI_URL}/api/forms/${documentId}/export-csv`}
45 className="bg-black text-white rounded px-4 py-2"
46 >
47 Export CSV
48 </a>
49 </div>
50
51 <form className="flex gap-3 mb-6 items-end">
52 <label className="flex flex-col text-sm">
53 From
54 <input type="date" name="from" defaultValue={from} className="border rounded px-2 py-1" />
55 </label>
56 <label className="flex flex-col text-sm">
57 To
58 <input type="date" name="to" defaultValue={to} className="border rounded px-2 py-1" />
59 </label>
60 <button type="submit" className="border rounded px-3 py-1">
61 Filter
62 </button>
63 </form>
64
65 <table className="w-full border-collapse text-sm">
66 <thead>
67 <tr className="border-b">
68 <th className="text-left p-2">Submitted</th>
69 {columns.map((col) => (
70 <th key={col} className="text-left p-2">{col}</th>
71 ))}
72 </tr>
73 </thead>
74 <tbody>
75 {submissions.map((s: any) => (
76 <tr key={s.documentId} className="border-b">
77 <td className="p-2">
78 {new Date(s.submittedAt).toLocaleString()}
79 </td>
80 {columns.map((col) => (
81 <td key={col} className="p-2">
82 {Array.isArray(s.data?.[col])
83 ? s.data[col].join(', ')
84 : s.data?.[col] ?? ''}
85 </td>
86 ))}
87 </tr>
88 ))}
89 </tbody>
90 </table>
91 </main>
92 );
93}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.
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:
options JSON array like ["Search", "Social media", "A friend"]["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.
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:
json config on each component.form.file-upload component backed by Strapi's Media Library.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.
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.