Every published form gets a unique, shareable URL where users can submit responses. The system handles validation, data collection, and success messaging automatically.
When you publish a form, it becomes accessible at a slug-based URL:
https://yourapp.com/f/{slug}
Slug Generation
Each form is assigned a unique slug on creation:
Auto-generated by Prisma
URL-safe characters only
Guaranteed unique across all forms
Persists for the lifetime of the form
Slugs are stored in the database with a unique constraint, preventing collisions even at scale.
The public form page dynamically renders fields based on the stored schema.
Page Load Flow
Extract slug from URL
Next.js dynamic routing captures the slug from the URL path. // app/f/[slug]/page.tsx:145-146
const params = useParams ();
const slug = params . slug as string ;
Fetch form data
The component fetches form configuration from the API using the slug. // app/f/[slug]/page.tsx:148-161
const fetchFormData = async () => {
try {
const response = await axios . get ( `/api/forms/ ${ slug } ` );
const { data } = response . data ;
setFormData ( data );
setIsLoading ( false );
} catch ( error : any ) {
setError ( error . message );
setIsLoading ( false );
}
};
fetchFormData ();
Render form fields
Fields are dynamically rendered based on their type using the renderField function. // app/f/[slug]/page.tsx:219-229
< form onSubmit = { handleSubmit } className = "space-y-6" >
{ formData . fields . map (( field : Field ) => (
< div key = {field. id } >
< label className = "block mb-2 font-medium" >
{ field . label }
{ field . required && < span className = "text-red-500" > * </ span >}
</ label >
{ renderField ( field , formValues [ field . id ], handleFieldChange )}
</ div >
))}
</ form >
Field Rendering
Each field type has specialized rendering logic:
Text & Email
Select Dropdown
Radio Buttons
Checkboxes
// app/f/[slug]/page.tsx:32-44
case "text" :
case "email" :
return (
< input
type = {field. type }
name = {field. id }
placeholder = {field. placeholder }
required = {field. required }
value = {value || "" }
onChange = {(e) => onChange (field.id, e.target.value)}
className = "w-full border rounded px-3 py-2"
/>
);
// app/f/[slug]/page.tsx:58-76
case "select" :
return (
< select
name = {field. id }
required = {field. required }
value = {value || "" }
onChange = {(e) => onChange (field.id, e.target.value)}
className = "w-full border rounded px-3 py-2"
>
< option value = "" className = "text-gray-700" >
Select an option
</ option >
{ field . options ?. map (( option : string ) => (
< option key = { option } value = { option } >
{ option }
</ option >
))}
</ select >
);
// app/f/[slug]/page.tsx:78-95
case "radio" :
return (
< div className = "space-y-2" >
{ field . options ?. map (( option : string ) => (
< label key = { option } className = "flex items-center gap-2" >
< input
type = "radio"
name = {field. id }
value = { option }
required = {field. required }
checked = { value === option }
onChange = {(e) => onChange (field.id, e.target.value)}
/>
< span >{ option } </ span >
</ label >
))}
</ div >
);
// app/f/[slug]/page.tsx:97-118
case "checkbox" :
return (
< div className = "space-y-2" >
{ field . options ?. map (( option : string ) => (
< label key = { option } className = "flex items-center gap-2" >
< input
type = "checkbox"
value = { option }
checked = {value?.includes(option) || false }
onChange = {(e) => {
const currentValues = value || [];
const newValues = e . target . checked
? [ ... currentValues , option ]
: currentValues . filter (( v : string ) => v !== option );
onChange ( field . id , newValues );
}}
/>
< span >{ option } </ span >
</ label >
))}
</ div >
);
Form values are tracked in React state:
// app/f/[slug]/page.tsx:141
const [ formValues , setFormValues ] = useState < Record < string , any >>({});
Value Updates
Each field change updates the centralized state object:
// app/f/[slug]/page.tsx:163-168
const handleFieldChange = ( fieldId : string , value : any ) => {
setFormValues (( prev ) => ({
... prev ,
[fieldId]: value ,
}));
};
Using a single state object with field IDs as keys makes it easy to construct the submission payload.
When the user clicks Submit, the form data is validated and sent to the API.
User clicks Submit
The form’s onSubmit handler is triggered.
Browser validation
HTML5 validation runs automatically for required fields and input types (email, number, etc.).
Prevent default & submit
The handler prevents default form behavior and sends an API request. // app/f/[slug]/page.tsx:170-188
const handleSubmit = async ( e : React . FormEvent ) => {
e . preventDefault ();
setIsSubmitting ( true );
try {
await axios . post ( "/api/forms/submit" , {
formId: formData ?. id ,
data: formValues ,
});
setSubmitSuccess ( true );
setFormValues ({});
} catch ( error : any ) {
console . error ( "Form submission failed:" , error );
setError ( "Failed to submit form. Please try again." );
} finally {
setIsSubmitting ( false );
}
};
Server-side validation
The API validates the submission (see next section).
Success message
On success, a thank you message is displayed. // app/f/[slug]/page.tsx:202-213
if ( submitSuccess ) {
return (
< div className = "min-h-screen p-8 max-w-xl mx-auto flex items-center justify-center" >
< div className = "text-center" >
< h2 className = "text-2xl font-bold mb-4" > Thank you !</ h2 >
< p className = "text-gray-600" >
Your response has been submitted successfully .
</ p >
</ div >
</ div >
);
}
Validation
Multiple layers of validation ensure data integrity.
Client-Side Validation
HTML5 validation runs automatically:
required attribute for mandatory fields
type="email" for email format
type="number" for numeric values
// app/f/[slug]/page.tsx:36
required = {field. required }
Server-Side Validation
The API performs comprehensive validation before storing responses.
Check form ID
// app/api/forms/submit/route.ts:11-16
if ( ! formId || formId . trim (). length === 0 ) {
return NextResponse . json (
{ error: "Form ID is required and cannot be empty" },
{ status: 400 },
);
}
Validate data object
// app/api/forms/submit/route.ts:18-23
if ( ! data || typeof data !== "object" ) {
return NextResponse . json (
{ error: "Form data is required and must be an object" },
{ status: 400 },
);
}
Verify form exists
// app/api/forms/submit/route.ts:25-31
const form = await prisma . forms . findUnique ({
where: { id: formId },
});
if ( ! form ) {
return NextResponse . json ({ error: "Form not found" }, { status: 404 });
}
Validate required fields
// app/api/forms/submit/route.ts:42-49
for ( const field of formFields ) {
if ( field . required && ! submittedKeys . includes ( field . id )) {
return NextResponse . json (
{ error: `Required field missing: ${ field . label } ` },
{ status: 400 },
);
}
}
Store response
// app/api/forms/submit/route.ts:51-56
const formResponse = await prisma . formsResponses . create ({
data: {
formId: formId ,
data: data ,
},
});
The API returns specific error messages for validation failures, helping users understand what went wrong.
Response Storage
Responses are stored in the FormsResponses table:
model FormsResponses {
id String @id @default ( cuid ())
formId String
data Json // User's submitted data
createdAt DateTime @default ( now ())
form Forms @relation ( fields : [ formId ], references : [ id ], onDelete : Cascade )
}
Key Features
The data field stores the entire submission as JSON, allowing flexible schemas without migrations. Example stored data: {
"full_name" : "John Doe" ,
"email" : "john@example.com" ,
"rating" : "Very Satisfied" ,
"comments" : "Great service!"
}
When a form is deleted, all its responses are automatically removed via onDelete: Cascade.
createdAt automatically records when each response was submitted for analytics and exports.
Error States
The public form page handles various error scenarios:
Loading
Form Not Found
API Error
Submission Failed
if ( isLoading ) {
return < div className = "p-8" > Loading form ...</ div > ;
}
if ( ! formData ) {
return < div className = "p-8" > Form not found </ div > ;
}
if ( error ) {
return < div className = "p-8 text-red-500" >{ error } </ div > ;
}
catch ( error : any ) {
console . error ( "Form submission failed:" , error );
setError ( "Failed to submit form. Please try again." );
}
UX Features
Loading States
The submit button shows loading state during submission:
// app/f/[slug]/page.tsx:231-237
< button
type = "submit"
disabled = { isSubmitting }
className = "w-full mt-4 px-4 py-2 bg-black text-white rounded hover:bg-gray-800 disabled:opacity-50"
>
{ isSubmitting ? "Submitting..." : "Submit" }
</ button >
Required Field Indicators
Required fields are clearly marked with a red asterisk:
// app/f/[slug]/page.tsx:222-225
< label className = "block mb-2 font-medium" >
{ field . label }
{ field . required && < span className = "text-red-500" > * </ span >}
</ label >
The combination of visual indicators and browser validation provides clear feedback before users submit.
API Endpoint
POST /api/forms/submit
Request Body:
{
"formId" : "cm123abc" ,
"data" : {
"full_name" : "John Doe" ,
"email" : "john@example.com" ,
"rating" : "Very Satisfied"
}
}
Success Response:
{
"success" : true ,
"message" : "Response submitted successfully" ,
"responseId" : "cm456def"
}
Error Response:
{
"error" : "Required field missing: Email Address"
}
Implementation: app/api/forms/submit/route.ts