Module 19 — Forms in Next.js
Forms are one of the most important parts of a real-world Next.js application.
Almost every application needs forms for:
- Login
- Registration
- Contact
- Search
- Course enrollment
- Profile updates
- Course creation
- Admin dashboards
- Content management
- Payments and checkout
A production-quality form is more than an <input> and a submit button.
A complete form architecture looks like:
1User Input 2 ↓ 3Form 4 ↓ 5Client Validation 6 ↓ 7Submit 8 ↓ 9Server Validation 10 ↓ 11Server Action / API 12 ↓ 13Database 14 ↓ 15Success / Error Response 16 ↓ 17UI Update
The most important principle is:
Client-side validation improves user experience, but server-side validation is the final security boundary.
What Is a Form?
A form collects information from a user.
A basic HTML form looks like:
1export default function ContactForm() { 2 return ( 3 <form> 4 <label htmlFor="name"> 5 Name 6 </label> 7 8 <input 9 id="name" 10 name="name" 11 type="text" 12 /> 13 14 <label htmlFor="email"> 15 Email 16 </label> 17 18 <input 19 id="email" 20 name="email" 21 type="email" 22 /> 23 24 <button type="submit"> 25 Send 26 </button> 27 </form> 28 ); 29}
The important HTML attributes are:
1name 2type 3id 4htmlFor 5required
The name attribute is particularly important when working with FormData and Server Actions.
Why Forms Need Special Handling
Suppose a user submits:
1Email: user@example.com 2Password: ********
The application needs to:
1Receive input 2 ↓ 3Validate input 4 ↓ 5Authenticate user 6 ↓ 7Create session 8 ↓ 9Return result
For a registration form:
1Name 2Email 3Password 4Confirm Password 5 ↓ 6Validate 7 ↓ 8Create User 9 ↓ 10Database
Forms therefore connect the UI layer to your backend logic.
Controlled Inputs
A controlled input stores its value in React state.
For example:
1"use client"; 2 3import { useState } from "react"; 4 5export default function SearchForm() { 6 const [query, setQuery] = 7 useState(""); 8 9 return ( 10 <form> 11 <input 12 type="text" 13 value={query} 14 onChange={(event) => 15 setQuery(event.target.value) 16 } 17 placeholder="Search courses" 18 /> 19 20 <button type="submit"> 21 Search 22 </button> 23 </form> 24 ); 25}
The data flow is:
1Input 2 ↓ 3onChange 4 ↓ 5React State 6 ↓ 7value 8 ↓ 9Input
The React state becomes the source of truth.
When Controlled Inputs Are Useful
Controlled inputs are especially useful when you need immediate access to the value.
Examples:
1Live search 2Character counter 3Dynamic validation 4Conditional fields 5Password strength indicator 6Interactive filters
For example:
1const [password, setPassword] = 2 useState(""); 3 4const strongPassword = 5 password.length >= 8;
Now the UI can respond immediately to the user's input.
Uncontrolled Inputs
An uncontrolled form lets the browser manage the input value.
Example:
1"use client"; 2 3export default function ContactForm() { 4 function handleSubmit( 5 event: React.FormEvent<HTMLFormElement> 6 ) { 7 event.preventDefault(); 8 9 const form = 10 new FormData( 11 event.currentTarget 12 ); 13 14 const name = 15 form.get("name"); 16 17 console.log(name); 18 } 19 20 return ( 21 <form onSubmit={handleSubmit}> 22 <input 23 name="name" 24 type="text" 25 /> 26 27 <button type="submit"> 28 Submit 29 </button> 30 </form> 31 ); 32}
Here, React does not need to store every keystroke in state.
The browser manages the input.
Controlled vs Uncontrolled
The difference can be visualized as:
1Controlled 2 3Input 4 ↓ 5React State 6 ↓ 7Input
versus:
1Uncontrolled 2 3Input 4 ↓ 5Browser DOM 6 ↓ 7FormData
Neither approach is automatically better.
Choose based on the requirements of the form.
When to Use Controlled Inputs
Use controlled inputs when you need:
1Real-time validation 2Live previews 3Dynamic UI 4Character counters 5Conditional fields 6Immediate state changes
Example:
1Password 2 ↓ 3Check strength 4 ↓ 5Update indicator
When to Use Uncontrolled Inputs
Uncontrolled inputs are often convenient for:
1Simple forms 2Server Actions 3Large forms 4Forms where you don't need every keystroke
They can also reduce unnecessary React state management.
Basic Form Validation
HTML already provides basic validation.
1<input 2 type="email" 3 name="email" 4 required 5/>
The browser checks that the field is not empty and that the value resembles an email address.
You can also specify:
1<input 2 type="text" 3 name="username" 4 required 5 minLength={3} 6 maxLength={30} 7/>
However, browser validation alone is not enough for a production application.
Client-Side Validation
Client-side validation happens in the browser.
For example:
1"use client"; 2 3import { useState } from "react"; 4 5export default function LoginForm() { 6 const [email, setEmail] = 7 useState(""); 8 9 const [error, setError] = 10 useState(""); 11 12 function handleSubmit( 13 event: React.FormEvent<HTMLFormElement> 14 ) { 15 event.preventDefault(); 16 17 if (!email.includes("@")) { 18 setError( 19 "Please enter a valid email" 20 ); 21 22 return; 23 } 24 25 setError(""); 26 } 27 28 return ( 29 <form onSubmit={handleSubmit}> 30 <input 31 type="email" 32 value={email} 33 onChange={(event) => 34 setEmail(event.target.value) 35 } 36 /> 37 38 {error && ( 39 <p>{error}</p> 40 )} 41 42 <button type="submit"> 43 Login 44 </button> 45 </form> 46 ); 47}
The user receives immediate feedback.
Why Client Validation Is Not Enough
A common mistake is thinking:
1Client Validation 2 ↓ 3Secure
This is incorrect.
The browser is controlled by the user.
A malicious user can bypass:
1JavaScript 2HTML validation 3React validation 4Browser UI
Therefore:
1Client Validation 2 ↓ 3Better UX 4 5Server Validation 6 ↓ 7Security
You should generally perform validation on the server before modifying data.
Server-Side Validation
Suppose a course creation form sends:
1{ 2 "title": "", 3 "price": -500 4}
The server should reject it.
Conceptually:
1Request 2 ↓ 3Validate 4 ↓ 5Invalid? 6 ↓ 7Return Error
Example:
1function validateCourse( 2 title: string, 3 price: number 4) { 5 if (!title.trim()) { 6 throw new Error( 7 "Course title is required" 8 ); 9 } 10 11 if (price < 0) { 12 throw new Error( 13 "Price cannot be negative" 14 ); 15 } 16}
The server should never blindly trust submitted form data.
FormData
Next.js forms commonly work with the browser's FormData API.
Example:
1function handleSubmit( 2 event: React.FormEvent<HTMLFormElement> 3) { 4 event.preventDefault(); 5 6 const formData = 7 new FormData( 8 event.currentTarget 9 ); 10 11 const email = 12 formData.get("email"); 13 14 const password = 15 formData.get("password"); 16 17 console.log( 18 email, 19 password 20 ); 21}
If your form contains:
1<input 2 name="email" 3/> 4 5<input 6 name="password" 7 type="password" 8/>
then:
1FormData 2 ├── email 3 └── password
The name attribute determines the field key.
FormData Values
FormData.get() returns a value that may be:
1string 2File 3null
Therefore, when processing form data, validate and convert the values appropriately.
For example:
1const email = 2 formData.get("email"); 3 4if ( 5 typeof email !== "string" 6) { 7 throw new Error( 8 "Invalid email" 9 ); 10}
This is safer than assuming everything is a string.
Server Actions
One of the most useful Next.js form features is Server Actions.
A Server Action allows a form to call server-side code directly.
Example:
1async function createCourse( 2 formData: FormData 3) { 4 "use server"; 5 6 const title = 7 formData.get("title"); 8 9 console.log(title); 10}
Then:
1<form action={createCourse}> 2 <input 3 name="title" 4 /> 5 6 <button type="submit"> 7 Create Course 8 </button> 9</form>
The important architecture is:
1Form 2 ↓ 3Server Action 4 ↓ 5Validation 6 ↓ 7Database / API
You don't necessarily need to create a separate client-side fetch() request for every form mutation.
Why Server Actions Are Useful
Server Actions are useful for operations such as:
1Create 2Update 3Delete 4Login workflows 5Database mutations 6Form submissions
For example:
1Course Creation Form 2 ↓ 3createCourse() 4 ↓ 5Validate 6 ↓ 7Database 8 ↓ 9Revalidate 10 ↓ 11Updated UI
This creates a natural server-side mutation flow.
Server Action With Validation
Example:
1async function createCourse( 2 formData: FormData 3) { 4 "use server"; 5 6 const title = 7 formData.get("title"); 8 9 if ( 10 typeof title !== "string" || 11 !title.trim() 12 ) { 13 throw new Error( 14 "Course title is required" 15 ); 16 } 17 18 // Create course 19}
The validation occurs on the server.
Server Action With Database
A simplified example:
1async function createCourse( 2 formData: FormData 3) { 4 "use server"; 5 6 const title = 7 formData.get("title"); 8 9 if ( 10 typeof title !== "string" || 11 !title.trim() 12 ) { 13 throw new Error( 14 "Title is required" 15 ); 16 } 17 18 await db.course.create({ 19 data: { 20 title, 21 }, 22 }); 23}
The architecture becomes:
1Browser 2 ↓ 3Form 4 ↓ 5Server Action 6 ↓ 7Validation 8 ↓ 9Database
Database credentials remain on the server.
Server Actions vs API Routes
Both approaches can be useful.
Server Action
1Form 2 ↓ 3Server Action 4 ↓ 5Database
API Route
1Form 2 ↓ 3fetch() 4 ↓ 5API Route 6 ↓ 7Backend 8 ↓ 9Database
Server Actions are particularly convenient when the form and mutation belong to the same Next.js application.
API routes are useful when:
- external clients need the endpoint
- mobile applications consume the API
- third-party integrations need HTTP access
- you are building a public API
Login Form
A login form might contain:
1<form> 2 <label htmlFor="email"> 3 Email 4 </label> 5 6 <input 7 id="email" 8 name="email" 9 type="email" 10 required 11 /> 12 13 <label htmlFor="password"> 14 Password 15 </label> 16 17 <input 18 id="password" 19 name="password" 20 type="password" 21 required 22 /> 23 24 <button type="submit"> 25 Login 26 </button> 27</form>
The production flow should be:
1Email 2Password 3 ↓ 4Client Validation 5 ↓ 6Server Action / API 7 ↓ 8Server Validation 9 ↓ 10Authentication 11 ↓ 12Session 13 ↓ 14Redirect
Never trust the client to authenticate a user.
Registration Form
A registration form could collect:
1Name 2Email 3Password 4Confirm Password
The flow:
1Registration Form 2 ↓ 3Validate fields 4 ↓ 5Validate password 6 ↓ 7Check existing user 8 ↓ 9Hash password 10 ↓ 11Create database record 12 ↓ 13Create session 14 ↓ 15Redirect
Passwords should never be stored as plain text.
Contact Form
A contact form might be much simpler:
1<form> 2 <input 3 name="name" 4 placeholder="Your name" 5 required 6 /> 7 8 <input 9 name="email" 10 type="email" 11 placeholder="Email" 12 required 13 /> 14 15 <textarea 16 name="message" 17 placeholder="Message" 18 required 19 /> 20 21 <button type="submit"> 22 Send Message 23 </button> 24</form>
Server-side validation should still happen before saving or forwarding the message.
Course Enrollment Form
Imagine:
1Course 2 ↓ 3Enroll Button 4 ↓ 5Enrollment Mutation 6 ↓ 7Database
The server should verify:
1User authenticated? 2Course exists? 3Already enrolled? 4Course available? 5Payment required? 6User authorized?
The client should not be trusted to answer these questions.
Profile Update Form
A profile form might contain:
1Name 2Bio 3Website 4Profile Image
The flow becomes:
1Profile Form 2 ↓ 3FormData 4 ↓ 5Server Validation 6 ↓ 7Authentication 8 ↓ 9Update User 10 ↓ 11Database
The server must verify that the authenticated user is allowed to modify the requested profile.
Search Forms
Search forms are different because they often don't mutate data.
A basic search form:
1<form method="GET"> 2 <input 3 name="q" 4 placeholder="Search courses" 5 /> 6 7 <button type="submit"> 8 Search 9 </button> 10</form>
The URL might become:
1/courses?q=nextjs
Next.js can then read the query parameter.
This is useful because search URLs can be:
- shared
- bookmarked
- indexed where appropriate
- refreshed without losing the search
Course Creation Form
An admin course creation form could include:
1Title 2Slug 3Description 4Price 5Category 6Thumbnail 7Published
A production architecture:
1Admin Form 2 ↓ 3FormData 4 ↓ 5Server Action 6 ↓ 7Authentication 8 ↓ 9Authorization 10 ↓ 11Validation 12 ↓ 13Database 14 ↓ 15Revalidation 16 ↓ 17Course List
Notice that authentication and authorization happen on the server.
Loading States
A form should provide feedback while an operation is running.
Bad UX:
1User clicks Submit 2 ↓ 3Nothing happens 4 ↓ 5User clicks again 6 ↓ 7Duplicate request
Better:
1User clicks Submit 2 ↓ 3Button disabled 4 ↓ 5"Creating..." 6 ↓ 7Request completes 8 ↓ 9Success / Error
For client-side forms, you can manage state:
1"use client"; 2 3import { useState } from "react"; 4 5export default function ContactForm() { 6 const [loading, setLoading] = 7 useState(false); 8 9 async function handleSubmit( 10 event: React.FormEvent<HTMLFormElement> 11 ) { 12 event.preventDefault(); 13 14 setLoading(true); 15 16 try { 17 // Submit form 18 } finally { 19 setLoading(false); 20 } 21 } 22 23 return ( 24 <form onSubmit={handleSubmit}> 25 <input name="email" /> 26 27 <button 28 type="submit" 29 disabled={loading} 30 > 31 {loading 32 ? "Sending..." 33 : "Send"} 34 </button> 35 </form> 36 ); 37}
Error Messages
Errors should be understandable.
Bad:
1Error 500
Better:
1Unable to create the course. 2Please try again.
For validation:
1Course title is required.
For authentication:
1Invalid email or password.
For authorization:
1You don't have permission to edit this course.
Avoid exposing sensitive backend details to users.
Field-Level Errors
Instead of displaying one generic error, you can associate errors with fields.
For example:
1Email 2[ invalid-email ] 3 4Password 5[ Password must contain at least 8 characters ]
A structured error might look like:
1type FormErrors = { 2 email?: string; 3 password?: string; 4 title?: string; 5};
Then:
1{errors.email && ( 2 <p>{errors.email}</p> 3)}
This makes large forms easier to use.
Success Messages
After a successful mutation, show clear feedback.
For example:
1✓ Course created successfully.
or:
1✓ Your profile has been updated.
For navigation-based flows, you may instead redirect:
1Create Course 2 ↓ 3Success 4 ↓ 5/admin/courses
The correct behavior depends on the application.
Form Validation With Zod
For more complex applications, a schema validation library such as Zod can provide reusable validation.
Example:
1import { z } from "zod"; 2 3const courseSchema = 4 z.object({ 5 title: z 6 .string() 7 .min(3) 8 .max(100), 9 10 price: z 11 .number() 12 .min(0), 13 });
Now validation rules are centralized.
You can use the same conceptual schema for:
1Form 2API 3Server Action 4Database boundary
Be careful with FormData, because values arrive as strings or files and may need conversion before validation.
Converting FormData
Suppose the form contains:
1<input 2 name="price" 3 type="number" 4/>
FormData still gives you a string-like submitted value.
You may need to convert it:
1const rawPrice = 2 formData.get("price"); 3 4const price = 5 Number(rawPrice);
Then validate:
1if ( 2 !Number.isFinite(price) || 3 price < 0 4) { 5 throw new Error( 6 "Invalid price" 7 ); 8}
This illustrates an important rule:
Validate and normalize incoming form data before using it.
File Uploads
Forms can also upload files.
1<form> 2 <input 3 type="file" 4 name="thumbnail" 5 /> 6 7 <button type="submit"> 8 Upload 9 </button> 10</form>
The submitted value can be a File.
On the server:
1const file = 2 formData.get("thumbnail"); 3 4if (!(file instanceof File)) { 5 throw new Error( 6 "Invalid file" 7 ); 8}
A production upload flow should additionally validate:
1File type 2File size 3File name 4Content 5Storage destination 6Authorization
Do not trust the extension alone.
Form Mutations
A mutation changes data.
Examples:
1POST 2PUT 3PATCH 4DELETE
Typical mutations include:
1Create user 2Update profile 3Create course 4Update course 5Delete course 6Enroll in course 7Submit comment
The general architecture is:
1User Action 2 ↓ 3Form 4 ↓ 5Mutation 6 ↓ 7Validation 8 ↓ 9Authorization 10 ↓ 11Database
Mutation vs Query
A useful mental model is:
1Query 2 ↓ 3Read data 4 5Mutation 6 ↓ 7Change data
Examples:
1getCourse() 2getCourses() 3getUser()
are queries.
While:
1createCourse() 2updateCourse() 3deleteCourse()
are mutations.
Forms are often the UI layer that triggers mutations.
Revalidation After Mutations
Suppose an admin creates a course.
Before:
1Course List 2 ├── React 3 └── Next.js
The admin submits:
1Create "TypeScript"
After the mutation:
1Course List 2 ├── React 3 ├── Next.js 4 └── TypeScript
The application may need to revalidate or refresh the relevant data.
Conceptually:
1Form 2 ↓ 3Mutation 4 ↓ 5Database Updated 6 ↓ 7Revalidate Data 8 ↓ 9UI Shows New Data
This is an important part of building dynamic Next.js applications.
Complete Server Action Example
A simplified course creation form:
1async function createCourse( 2 formData: FormData 3) { 4 "use server"; 5 6 const title = 7 formData.get("title"); 8 9 const description = 10 formData.get("description"); 11 12 if ( 13 typeof title !== "string" || 14 title.trim().length < 3 15 ) { 16 throw new Error( 17 "Title must contain at least 3 characters" 18 ); 19 } 20 21 if ( 22 typeof description !== "string" || 23 !description.trim() 24 ) { 25 throw new Error( 26 "Description is required" 27 ); 28 } 29 30 // Save to database 31}
The form:
1export default function CreateCourseForm() { 2 return ( 3 <form action={createCourse}> 4 <input 5 name="title" 6 placeholder="Course title" 7 /> 8 9 <textarea 10 name="description" 11 placeholder="Course description" 12 /> 13 14 <button type="submit"> 15 Create Course 16 </button> 17 </form> 18 ); 19}
The architecture is:
1Form 2 ↓ 3createCourse() 4 ↓ 5Server 6 ↓ 7Validate 8 ↓ 9Database
Production Form Architecture
For a serious application, think in layers:
1 FORM 2 │ 3 ▼ 4 Client Validation 5 │ 6 ▼ 7 Server Action / API 8 │ 9 ▼ 10 Authentication 11 │ 12 ▼ 13 Authorization 14 │ 15 ▼ 16 Server Validation 17 │ 18 ▼ 19 Mutation 20 │ 21 ▼ 22 Database 23 │ 24 ▼ 25 Revalidation 26 │ 27 ▼ 28 Updated UI
Each layer solves a different problem.
Example: Complete Login Architecture
1Login Form 2 ↓ 3Email + Password 4 ↓ 5Client Validation 6 ↓ 7Server Action 8 ↓ 9Server Validation 10 ↓ 11Find User 12 ↓ 13Verify Password 14 ↓ 15Create Session 16 ↓ 17Set Secure Cookie 18 ↓ 19Redirect
The client should never decide that a user is authenticated.
Authentication must be established by trusted server-side logic.
Example: Complete Course Enrollment
1Enroll Button/Form 2 ↓ 3Server Action 4 ↓ 5Authenticate User 6 ↓ 7Find Course 8 ↓ 9Check Existing Enrollment 10 ↓ 11Check Course Availability 12 ↓ 13Create Enrollment 14 ↓ 15Database 16 ↓ 17Revalidate Course Data 18 ↓ 19Show "Enrolled"
This is a realistic mutation workflow.
Example: Admin Course Editor
A larger form might look like:
1┌──────────────────────────────┐ 2│ Course Title │ 3│ [__________________________] │ 4│ │ 5│ Description │ 6│ [__________________________] │ 7│ [__________________________] │ 8│ │ 9│ Price │ 10│ [__________________________] │ 11│ │ 12│ Category │ 13│ [__________________________] │ 14│ │ 15│ Thumbnail │ 16│ [ Choose File ] │ 17│ │ 18│ [ Save Course ] │ 19└──────────────────────────────┘
The architecture:
1Admin Editor 2 ↓ 3FormData 4 ↓ 5Server Action 6 ↓ 7Authentication 8 ↓ 9Authorization 10 ↓ 11Validation 12 ↓ 13File Processing 14 ↓ 15Database 16 ↓ 17Revalidation
This is much closer to a real production application than simply calling fetch() from a button.
Common Form Mistakes
Mistake 1 — Only validating on the client
Bad:
1Browser Validation 2 ↓ 3Database
Better:
1Browser Validation 2 ↓ 3Server Validation 4 ↓ 5Database
Mistake 2 — Trusting hidden fields
Never assume a hidden field is trustworthy.
For example:
1<input 2 type="hidden" 3 name="userId" 4 value="123" 5/>
A user can modify it.
The server should determine the authenticated user from the session rather than trusting the submitted userId.
Mistake 3 — Exposing secrets
Never put:
1Database passwords 2Private API keys 3JWT secrets 4Internal tokens
inside client-side code.
Mistake 4 — No loading state
Users may submit the form multiple times.
Provide:
1Submitting... 2Saving... 3Creating... 4Deleting...
when appropriate.
Mistake 5 — Poor error handling
Don't show:
1Internal Server Error
when a useful user-facing message can be provided.
Mistake 6 — No authorization
Authentication answers:
Who are you?
Authorization answers:
Are you allowed to perform this operation?
Both matter.
For example:
1User authenticated? 2 ↓ 3Yes 4 ↓ 5Is user an admin? 6 ↓ 7Yes 8 ↓ 9Allow course deletion
Forms and Accessibility
Forms should also be accessible.
Use:
1<label htmlFor="email"> 2 Email 3</label> 4 5<input 6 id="email" 7 name="email" 8/>
Instead of relying only on placeholders.
For validation errors, connect the error to the input where appropriate:
1<input 2 id="email" 3 name="email" 4 aria-invalid={!!errors.email} 5 aria-describedby="email-error" 6/> 7 8{errors.email && ( 9 <p id="email-error"> 10 {errors.email} 11 </p> 12)}
Good forms should work for keyboard users and assistive technologies as well.
Recommended Form Architecture
For most production Next.js applications:
1 UI 2 │ 3 ▼ 4 Form 5 │ 6 ┌───────┴───────┐ 7 ▼ ▼ 8 Browser Server 9 Validation Validation 10 │ │ 11 └───────┬───────┘ 12 ▼ 13 Server Action 14 / API 15 │ 16 ▼ 17 Authentication 18 │ 19 ▼ 20 Authorization 21 │ 22 ▼ 23 Mutation 24 │ 25 ▼ 26 Database 27 │ 28 ▼ 29 Revalidation 30 │ 31 ▼ 32 Updated UI
Module 19 Learning Checklist
After completing this module, you should understand:
- HTML forms
- Controlled inputs
- Uncontrolled inputs
FormData- Client-side validation
- Server-side validation
- Form submission
- Loading states
- Error messages
- Success messages
- Field-level validation errors
- Server Actions
- Mutations
- API-based form submission
- Login forms
- Registration forms
- Contact forms
- Course enrollment
- Profile updates
- Search forms
- Course creation
- Admin content editors
- File uploads
- Authentication
- Authorization
- Form accessibility
- Revalidation after mutations
- Secure server-side form processing
Final Mental Model
Remember this:
1 USER 2 ↓ 3 FORM 4 ↓ 5 Client Validation 6 ↓ 7 Server Action / API 8 ↓ 9 Authentication 10 ↓ 11 Authorization 12 ↓ 13 Server Validation 14 ↓ 15 Mutation 16 ↓ 17 DATABASE 18 ↓ 19 Revalidation 20 ↓ 21 SUCCESS / ERROR 22 ↓ 23 UPDATED UI
The most important principle is:
Forms are a boundary between untrusted user input and trusted application logic. Validate on the client for a better experience, but always validate and authorize on the server before changing data.