Next.js Server Actions Tutorial: Best Practices for Form Handling and Validation

Friday night, 10:30 PM. You’re staring at code for a user registration form. Your folder already contains four files: form component, API Route, type definitions, error handling… You suddenly realize you’ve written nearly 200 lines of code just to handle a simple form submission.
Is there an easier way?
Enter Server Actions. This Next.js App Router feature can simplify your form handling workflow by 80%. No API Routes needed, no manual fetch calls, not even that tedious state management. Sounds great, but you probably have questions: Is this thing actually secure? How do I handle validation? What about loading states?
Honestly, I had these concerns when I first started using it. After a few months and some lessons learned, I want to share my practical insights on using Next.js Server Actions for form handling—from basic submission to Zod validation, security practices, and UX optimization. This article will walk you through real code examples to help you quickly get up to speed with this feature.
Server Actions Basics
What are Server Actions?
Server Actions are async functions that run on the server. You mark them with 'use server', then use them directly in your form’s action attribute. When the form submits, it automatically calls this function, handling data processing, database operations, cache updates… all on the server.
Key characteristics:
- Type-safe: TypeScript can check the entire chain
- Zero config: No need to create an
/apifolder - Auto-handling: FormData is automatically passed in
Two ways to write them—you can inline Server Actions in your component or put them in a separate file (module-level):
// Method 1: Inline in component
export default function Page() {
async function createUser(formData: FormData) {
'use server' // Mark as Server Action
const name = formData.get('name')
// Process data...
}
return <form action={createUser}>...</form>
}
// Method 2: Separate file (recommended)
// app/actions.ts
'use server' // File-level marking
export async function createUser(formData: FormData) {
const name = formData.get('name')
// Process data...
}You might ask: What’s the difference between Server Actions and traditional API Routes? When should you use which?
Here’s a comparison table:
| Feature | Server Actions | API Routes |
|---|---|---|
| Purpose | Form submission, data mutations | RESTful API, external calls |
| HTTP Methods | POST only | GET/POST/PUT/DELETE, etc. |
| Type Safety | Naturally type-safe | Requires manual type definitions |
| Invocation | Direct function call | fetch requests |
| Best For | Internal logic, forms | Public APIs, third-party integrations |
| Code Volume | Less | Relatively more |
Simply put: Use Server Actions internally, API Routes externally. If you’re just handling forms within your own app, Server Actions are sufficient. But if you need to provide interfaces to other systems or need GET requests, stick with API Routes.
According to Vercel’s 2025 survey, 63% of developers are already using Server Actions in production. This isn’t some experimental feature anymore.
Your First Server Actions Example
Let’s jump straight into code with a simple login form:
// app/login/page.tsx
export default function LoginPage() {
async function handleLogin(formData: FormData) {
'use server' // Mark as server function
// Get data from form
const email = formData.get('email') as string
const password = formData.get('password') as string
// Handle login logic (simplified for demo)
console.log('Login attempt:', email)
// In real projects, you'd verify user, generate token, etc.
}
return (
<form action={handleLogin}>
<input
type="email"
name="email"
placeholder="Email"
required
/>
<input
type="password"
name="password"
placeholder="Password"
required
/>
<button type="submit">Login</button>
</form>
)
}That’s it. Key points:
'use server': Tells Next.js this function runs on the serverformData.get(): Gets values using fieldnameattributesaction={handleLogin}: Automatically calls on form submission
The result: Click submit, browser doesn’t refresh, data goes straight to the server for processing. Way less code than writing fetch, useState, error handling…
But this is just the basics. In real projects, you need validation, error display, loading states. Let’s keep going.
Form Validation in Practice
Using Zod for Form Validation
Relying only on client-side required attributes? Too naive. Users can open browser dev tools and bypass those validations. Server-side validation is essential.
This is where Zod comes in. It validates data format on the server, returns errors immediately if found, preventing dirty data from reaching your database.
First, install Zod:
npm install zodThen define validation rules:
// app/actions.ts
'use server'
import { z } from 'zod'
// Define validation schema
const SignupSchema = z.object({
name: z.string().min(2, 'Name must be at least 2 characters'),
email: z.string().email('Invalid email format'),
password: z.string().min(8, 'Password must be at least 8 characters'),
})
export async function signup(formData: FormData) {
// Extract data from FormData
const rawData = {
name: formData.get('name'),
email: formData.get('email'),
password: formData.get('password'),
}
// Validate data
const result = SignupSchema.safeParse(rawData)
// Return errors if validation fails
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors, // Field-level errors
}
}
// Validation passed, handle business logic
const { name, email, password } = result.data
// Create user, save to database, etc...
console.log('Creating user:', { name, email })
return {
success: true,
message: 'Registration successful!',
}
}Key points:
safeParsedoesn’t throw: On failure returns{ success: false, error: ... }, letting you handle errors gracefullyflatten().fieldErrors: Converts validation errors to{ name: ['error1'], email: ['error2'] }format for easy display- Structured return: Includes
successflag and error info for client-side display decisions
But there’s still a question: How do you display these errors in the form? That’s where useActionState comes in.
Displaying Validation Errors: useActionState
useActionState is a Hook introduced in React 19 (previously called useFormState), specifically for handling state returned from Server Actions. It:
- Saves server-returned data to component state
- Provides a wrapped action function
- Tells you if the form is submitting
Let’s look at the code:
// app/signup/page.tsx
'use client' // Using Hooks requires client component marking
import { useActionState } from 'react'
import { signup } from '@/app/actions'
export default function SignupPage() {
// Define initial state
const initialState = { success: false, errors: {}, message: '' }
// useActionState receives: Server Action and initial state
const [state, formAction, isPending] = useActionState(signup, initialState)
return (
<form action={formAction}> {/* Use formAction instead of raw action */}
<div>
<label>Name</label>
<input
type="text"
name="name"
required
/>
{/* Display field errors */}
{state.errors?.name && (
<p className="error">{state.errors.name[0]}</p>
)}
</div>
<div>
<label>Email</label>
<input
type="email"
name="email"
required
/>
{state.errors?.email && (
<p className="error">{state.errors.email[0]}</p>
)}
</div>
<div>
<label>Password</label>
<input
type="password"
name="password"
required
/>
{state.errors?.password && (
<p className="error">{state.errors.password[0]}</p>
)}
</div>
<button type="submit" disabled={isPending}>
{isPending ? 'Submitting...' : 'Sign Up'}
</button>
{/* Display success message */}
{state.success && (
<p className="success">{state.message}</p>
)}
</form>
)
}The workflow:
- User submits form → calls
signup - Server validation fails → returns
{ success: false, errors: {...} } useActionStatestores this result instate- Component re-renders, displaying error messages
isPending is true during form submission, becomes false when complete. You can use it to disable buttons and show loading text.
But you might notice: User input gets lost after validation fails. To preserve form data, you can return a values field and set it to inputs using defaultValue. Won’t expand on that here—the key point is understanding useActionState’s role: Connecting client components and Server Actions to simplify state management.
User Experience Optimization
Loading States and Preventing Duplicate Submissions
We used isPending to show loading above, but there’s actually another Hook: useFormStatus. These two can be confusing—I was puzzled at first too.
Simply put:
useActionState’sisPending: Suitable for use in the form componentuseFormStatus’spending: Suitable for form child components (like submit buttons)
useFormStatus has a restriction: Must be called within a <form> child component, not directly in the form component. Sounds inconvenient, but the benefit is you can extract buttons as independent reusable components.
Here’s an example, extracting the submit button:
// components/SubmitButton.tsx
'use client'
import { useFormStatus } from 'react-dom'
export function SubmitButton({ children }: { children: React.ReactNode }) {
const { pending } = useFormStatus() // Get form submission status
return (
<button
type="submit"
disabled={pending}
className={pending ? 'loading' : ''}
>
{pending ? 'Submitting...' : children}
</button>
)
}Then use it directly in your form:
// app/signup/page.tsx
'use client'
import { useActionState } from 'react'
import { signup } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'
export default function SignupPage() {
const [state, formAction] = useActionState(signup, { success: false, errors: {} })
return (
<form action={formAction}>
{/* Form fields... */}
<SubmitButton>Sign Up</SubmitButton> {/* Handles loading automatically */}
{state.errors?.general && (
<p className="error">{state.errors.general}</p>
)}
</form>
)
}Now the button’s loading logic is completely encapsulated. During submission:
- Button automatically disables, preventing duplicate submissions
- Text changes to “Submitting…”
- You can add a spinner animation
What’s the difference between pending and isPending?
| Feature | useActionState’s isPending | useFormStatus’s pending |
|---|---|---|
| Call Location | Inside form component | Inside form child component |
| Use Case | Need access to overall form state | Only care about submission state for standalone button |
| Flexibility | Can get both state and pending | Can only get pending |
In real projects, I typically use:
- Form logic complex, need multiple states → use
useActionState - Just making a generic submit button → use
useFormStatus
Progressive Enhancement
There’s a pretty cool feature: Server Actions support progressive enhancement. What does that mean? Even if a user’s browser has JavaScript disabled, the form can still submit.
This works because Server Actions essentially leverage the browser’s native <form> submission mechanism. Next.js intercepts the submission process when JavaScript is available, making it an AJAX request; without JavaScript, it falls back to traditional form submission.
Real use cases? Honestly, not many. Which website works without JavaScript these days… But for accessibility and crawler-friendliness, it’s a plus. And you don’t have to do anything—Next.js handles it automatically.
Security and Best Practices
Server Actions Security
This is the most overlooked part. Many people think Server Actions run on the server, so they’re automatically secure. Dead wrong.
Server Actions are essentially public API endpoints. While Next.js generates a hard-to-guess ID for them, that’s just “obfuscation,” not real security. Anyone with some technical know-how can open browser dev tools, check network requests, find the Action ID, and call it manually.
Next.js provides some built-in protections:
- CSRF Protection: Server Actions can only be called via POST requests, and it checks if Origin and Host headers match. Cross-site requests get rejected.
- Secure Action IDs: Each Action has an encrypted ID that’s not easy to enumerate.
- Closure Variable Encryption: If you use external variables in an Action, Next.js encrypts them.
But this isn’t nearly enough. You must do these things:
1. Input Validation
Never trust client-side data. We covered using Zod for validation earlier—this is essential.
2. Authentication
Check if the user is logged in. Every Action requiring permissions needs identity verification.
3. Authorization
Being logged in doesn’t mean having permission. For example, user A can’t delete user B’s data—verify operation permissions.
Here’s a practical example:
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
const DeletePostSchema = z.object({
postId: z.string().min(1),
})
export async function deletePost(formData: FormData) {
// 1. Validate input
const rawData = {
postId: formData.get('postId'),
}
const result = DeletePostSchema.safeParse(rawData)
if (!result.success) {
return { success: false, error: 'Invalid request' }
}
const { postId } = result.data
// 2. Authentication: Check if user is logged in
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) {
return { success: false, error: 'Please log in first' }
}
// 3. Get current user
const currentUser = await getUserFromSession(sessionToken)
if (!currentUser) {
return { success: false, error: 'Session expired' }
}
// 4. Authorization: Check if this post belongs to current user
const post = await getPost(postId)
if (!post) {
return { success: false, error: 'Post not found' }
}
if (post.authorId !== currentUser.id) {
return { success: false, error: "You don't have permission to delete this post" }
}
// 5. Execute operation
await deletePostFromDB(postId)
return { success: true, message: 'Deleted successfully' }
}This example demonstrates the complete security check flow: Input validation → Authentication → Authorization → Execute operation. None can be skipped.
There’s also a useful tool to recommend: the next-safe-action library. It provides a middleware mechanism for unified handling of validation, authentication, and error handling:
import { createSafeActionClient } from 'next-safe-action'
// Create an action client with authentication
const actionClient = createSafeActionClient({
// Middleware: Check user login status
async middleware() {
const session = await getSession()
if (!session) {
throw new Error('Not logged in')
}
return { userId: session.userId }
},
})
// Automatically includes authentication check when used
export const deletePost = actionClient
.schema(DeletePostSchema)
.action(async ({ parsedInput, ctx }) => {
const { postId } = parsedInput
const { userId } = ctx // Get user ID from middleware
// Execute deletion...
})This way, all Actions requiring authentication reuse the same logic. Much cleaner code.
Remember: Server Actions aren’t black magic—they’re just API endpoints. Don’t skip any security measures you should take.
Practical Case: Form with Authentication
Here’s a complete example—a comment form that only logged-in users can submit:
// app/actions.ts
'use server'
import { cookies } from 'next/headers'
import { z } from 'zod'
import { revalidatePath } from 'next/cache'
const CommentSchema = z.object({
postId: z.string(),
content: z.string().min(1, 'Comment cannot be empty').max(500, 'Comment limited to 500 characters'),
})
export async function addComment(formData: FormData) {
// 1. Validate input
const rawData = {
postId: formData.get('postId'),
content: formData.get('content'),
}
const result = CommentSchema.safeParse(rawData)
if (!result.success) {
return {
success: false,
errors: result.error.flatten().fieldErrors,
}
}
// 2. Authentication
const cookieStore = await cookies()
const sessionToken = cookieStore.get('session')?.value
if (!sessionToken) {
return {
success: false,
error: 'Please log in before commenting',
}
}
const user = await getUserFromSession(sessionToken)
if (!user) {
return {
success: false,
error: 'Session expired, please log in again',
}
}
// 3. Save comment
const { postId, content } = result.data
await saveComment({
postId,
content,
authorId: user.id,
authorName: user.name,
createdAt: new Date(),
})
// 4. Revalidate page cache so comment shows immediately
revalidatePath(`/posts/${postId}`)
return {
success: true,
message: 'Comment posted successfully',
}
}Client component:
// app/posts/[id]/CommentForm.tsx
'use client'
import { useActionState } from 'react'
import { addComment } from '@/app/actions'
import { SubmitButton } from '@/components/SubmitButton'
export function CommentForm({ postId }: { postId: string }) {
const [state, formAction] = useActionState(addComment, {
success: false,
errors: {},
})
return (
<form action={formAction}>
{/* Hidden field to pass postId */}
<input type="hidden" name="postId" value={postId} />
<textarea
name="content"
placeholder="Write your comment..."
rows={4}
required
/>
{state.errors?.content && (
<p className="error">{state.errors.content[0]}</p>
)}
{state.error && (
<p className="error">{state.error}</p>
)}
{state.success && (
<p className="success">{state.message}</p>
)}
<SubmitButton>Post Comment</SubmitButton>
</form>
)
}This example combines all the points we covered:
- Zod input validation
- User login status check
- Using
useActionStatefor state handling - Using
revalidatePathto refresh cache - Submit button with loading state
Complete form handling flow, production-ready.
Advanced Techniques
Passing Additional Parameters
Sometimes you need to pass parameters beyond form fields. For example, when editing an article, besides form content, you need to pass the article ID.
One approach is using hidden fields:
<input type="hidden" name="postId" value={postId} />But there’s a more elegant way: using JavaScript’s bind method.
// app/actions.ts
'use server'
export async function updatePost(postId: string, formData: FormData) {
const title = formData.get('title') as string
const content = formData.get('content') as string
// Update article...
await updatePostInDB(postId, { title, content })
return { success: true }
}When calling from the client:
// app/posts/[id]/edit/page.tsx
'use client'
import { updatePost } from '@/app/actions'
export default function EditPost({ postId }: { postId: string }) {
// Use bind to bind postId parameter
const updatePostWithId = updatePost.bind(null, postId)
return (
<form action={updatePostWithId}>
<input type="text" name="title" required />
<textarea name="content" required />
<button type="submit">Update</button>
</form>
)
}bind(null, postId) creates a new function with postId fixed as the first parameter. When the form submits, FormData gets passed as the second parameter.
Use cases: Edit, delete, and other operations requiring IDs.
Data Revalidation
After Server Actions process data, related page caches might be stale. Next.js provides two functions to refresh cache:
1. revalidatePath
Refresh by path:
import { revalidatePath } from 'next/cache'
export async function createPost(formData: FormData) {
// Create article...
// Refresh homepage article list
revalidatePath('/')
// Refresh article detail page
revalidatePath(`/posts/${newPostId}`)
return { success: true }
}2. revalidateTag
Refresh by tag (requires tagging during fetch):
// Tag when fetching data
fetch('https://api.example.com/posts', {
next: { tags: ['posts'] }
})
// Refresh all caches with 'posts' tag in Server Action
import { revalidateTag } from 'next/cache'
export async function createPost(formData: FormData) {
// Create article...
revalidateTag('posts') // Refresh all related caches
return { success: true }
}When to use which?
- Fixed paths, small number → use
revalidatePath - Data scattered across many pages → use
revalidateTag
I generally prefer revalidatePath for simplicity. Only consider tags when one operation affects many pages.
Optimistic Updates
Some operations almost never fail, like likes or favorites. In these cases, you can use optimistic updates: Show success in the UI first, submit in the background later.
React 19 provides the useOptimistic Hook:
'use client'
import { useOptimistic } from 'react'
import { likePost } from '@/app/actions'
export function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
const [optimisticLikes, setOptimisticLikes] = useOptimistic(initialLikes)
async function handleLike() {
// Update UI immediately (optimistic)
setOptimisticLikes(optimisticLikes + 1)
// Submit in background
await likePost(postId)
}
return (
<button onClick={handleLike}>
👍 {optimisticLikes}
</button>
)
}User clicks button, number increases immediately, no waiting for server response. Smooth experience.
But note: Only use this for operations with extremely high success rates. If it fails, you have to roll back the UI, which becomes more troublesome.
Conclusion
After all that, here are three takeaways:
Server Actions simplify form handling, but they’re not a silver bullet. Use them for internal forms, but external APIs still need Route Handlers. Don’t use Server Actions for everything blindly.
Security is your responsibility. The framework only provides basic protection—input validation, authentication, authorization checks… can’t skip any. Don’t expect Next.js to handle everything.
UX details matter. Loading states, error messages, optimistic updates… these small details determine whether users think your app is “okay” or “really good.” Combine
useActionStateanduseFormStatusto handle these well.
Try starting with the simplest form. Create a Server Action, add Zod validation, show a loading state—you’ll master 80% of the use cases. The remaining 20% (cache refresh, optimistic updates, etc.) can wait until you need them to check the official docs.
Both Next.js and React are iterating quickly, and Server Actions APIs might still change. Remember to follow official documentation updates so the code in this article doesn’t become outdated too quickly.
Go try it in your project now. Next time you write form submission code, you might discover it can actually be this simple.
FAQ
What are Server Actions?
Key features:
• Marked with 'use server'
• Used directly in form action attribute
• Automatic FormData handling
• Type-safe with TypeScript
• No API Routes needed
Example:
'use server'
export async function createUser(formData: FormData) {
const name = formData.get('name')
// ... server logic
}
Are Server Actions secure?
• Run entirely on server
• Automatically protected from CSRF
• But must validate input (use Zod)
• Never trust client-side data
Best practice: Always validate with Zod schema before processing.
How do I validate forms with Server Actions?
Example:
import { z } from 'zod'
const schema = z.object({
name: z.string().min(1),
email: z.string().email()
})
'use server'
export async function submitForm(formData: FormData) {
const result = schema.safeParse({
name: formData.get('name'),
email: formData.get('email')
})
if (!result.success) {
return { errors: result.error.flatten() }
}
// ... process data
}
How do I handle loading states?
Example:
'use client'
function SubmitButton() {
const { pending } = useFormStatus()
return <button disabled={pending}>
{pending ? 'Submitting...' : 'Submit'}
</button>
}
Or use useActionState for form state management.
How do I handle errors?
1) Return errors from Server Action
2) Use useActionState to manage state
3) Display errors in form
4) Use error.tsx for unexpected errors
Example:
const [state, formAction] = useActionState(createUser, null)
if (state?.errors) {
return <div>{state.errors.name}</div>
}
Can I use Server Actions without forms?
• Form actions (automatic)
• Button onClick handlers
• Any client component
Example:
'use client'
function Button() {
const handleClick = async () => {
await deleteUser(userId)
}
return <button onClick={handleClick}>Delete</button>
}
What are common Server Actions pitfalls?
• Not validating input (security risk)
• Not handling errors properly
• Forgetting 'use server' directive
• Not using TypeScript types
• Mixing client and server code
Best practices:
• Always validate with Zod
• Use useActionState for state
• Handle errors gracefully
• Keep Server Actions pure (no side effects in client)
10 min read · Published on: Dec 19, 2025 · Modified on: Jan 22, 2026
Related Posts
Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation

Next.js E-commerce in Practice: Complete Guide to Shopping Cart and Stripe Payment Implementation
Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload

Complete Guide to Next.js File Upload: S3/Qiniu Cloud Presigned URL Direct Upload
Next.js Unit Testing Guide: Complete Jest + React Testing Library Setup


Comments
Sign in with GitHub to leave a comment