Switch Language
Toggle Theme

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 /api folder
  • 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:

FeatureServer ActionsAPI Routes
PurposeForm submission, data mutationsRESTful API, external calls
HTTP MethodsPOST onlyGET/POST/PUT/DELETE, etc.
Type SafetyNaturally type-safeRequires manual type definitions
InvocationDirect function callfetch requests
Best ForInternal logic, formsPublic APIs, third-party integrations
Code VolumeLessRelatively 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:

  1. 'use server': Tells Next.js this function runs on the server
  2. formData.get(): Gets values using field name attributes
  3. action={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 zod

Then 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:

  1. safeParse doesn’t throw: On failure returns { success: false, error: ... }, letting you handle errors gracefully
  2. flatten().fieldErrors: Converts validation errors to { name: ['error1'], email: ['error2'] } format for easy display
  3. Structured return: Includes success flag 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:

  1. User submits form → calls signup
  2. Server validation fails → returns { success: false, errors: {...} }
  3. useActionState stores this result in state
  4. 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’s isPending: Suitable for use in the form component
  • useFormStatus’s pending: 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?

FeatureuseActionState’s isPendinguseFormStatus’s pending
Call LocationInside form componentInside form child component
Use CaseNeed access to overall form stateOnly care about submission state for standalone button
FlexibilityCan get both state and pendingCan 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:

  1. 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.
  2. Secure Action IDs: Each Action has an encrypted ID that’s not easy to enumerate.
  3. 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 useActionState for state handling
  • Using revalidatePath to 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:

  1. 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.

  2. 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.

  3. UX details matter. Loading states, error messages, optimistic updates… these small details determine whether users think your app is “okay” or “really good.” Combine useActionState and useFormStatus to 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?
Server Actions are async functions that run on the server.

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?
Yes, but need proper validation:
• 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?
Use Zod for validation:

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?
Use useFormStatus hook:

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?
Methods:
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?
Yes. Server Actions can be called from:
• 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?
Common mistakes:
• 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

Comments

Sign in with GitHub to leave a comment

Related Posts