March 20, 2026·8 min·By

Next.js Server Actions: The Right Way to Handle Forms and Mutations

Next.jsServer ActionsReactformsApp Router

Server Actions let you write server-side functions that your components call directly -- no API route boilerplate, no fetch calls, no separate endpoint file.

Basic Server Action

// app/actions.ts
'use server'

import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export async function createPost(formData: FormData) {
    const title = formData.get('title') as string
    const content = formData.get('content') as string

    if (!title || !content) {
        return { error: 'Title and content are required' }
    }

    const post = await db.posts.create({ title, content })
    revalidatePath('/blog')  // Invalidate blog page cache

    return { success: true, post }
}
// app/new-post/page.tsx (Server Component)
import { createPost } from '../actions'

export default function NewPostPage() {
    return (
        <form action={createPost}>
            <input name="title" placeholder="Title" required />
            <textarea name="content" placeholder="Content" required />
            <button type="submit">Create Post</button>
        </form>
    )
}

This works with JavaScript disabled (progressive enhancement) and doesn't need an API route.

Validation with Zod

'use server'
import { z } from 'zod'

const PostSchema = z.object({
    title: z.string().min(1, 'Title is required').max(100),
    content: z.string().min(10, 'Content too short').max(10000),
    published: z.coerce.boolean()
})

export async function createPost(formData: FormData) {
    const parsed = PostSchema.safeParse({
        title: formData.get('title'),
        content: formData.get('content'),
        published: formData.get('published') === 'on'
    })

    if (!parsed.success) {
        return {
            errors: parsed.error.flatten().fieldErrors
        }
    }

    const post = await db.posts.create(parsed.data)
    revalidatePath('/blog')
    return { post }
}

useActionState for Error Handling

'use client'
import { useActionState } from 'react'
import { createPost } from '../actions'

const initialState = { errors: {}, post: null }

export function CreatePostForm() {
    const [state, formAction, isPending] = useActionState(createPost, initialState)

    return (
        <form action={formAction}>
            <div>
                <input name="title" placeholder="Title" />
                {state.errors?.title && (
                    <p className="text-red-500">{state.errors.title[0]}</p>
                )}
            </div>
            <div>
                <textarea name="content" placeholder="Content" />
                {state.errors?.content && (
                    <p className="text-red-500">{state.errors.content[0]}</p>
                )}
            </div>
            <button type="submit" disabled={isPending}>
                {isPending ? 'Creating...' : 'Create Post'}
            </button>
        </form>
    )
}

Optimistic Updates

'use client'
import { useOptimistic } from 'react'
import { likePost } from '../actions'

export function LikeButton({ postId, initialCount }: { postId: string; initialCount: number }) {
    const [optimisticCount, addOptimisticLike] = useOptimistic(
        initialCount,
        (current, _) => current + 1
    )

    return (
        <form action={async () => {
            addOptimisticLike(null)  // Immediately update UI
            await likePost(postId)   // Then do the real action
        }}>
            <button type="submit">
                {optimisticCount} likes
            </button>
        </form>
    )
}

The UI updates instantly -- if the server action fails, React rolls back the optimistic update.

Authentication in Server Actions

'use server'
import { cookies } from 'next/headers'
import { verifyToken } from '@/lib/auth'

export async function deletePost(postId: string) {
    // Always verify auth in server actions
    const token = cookies().get('session')?.value
    const user = await verifyToken(token)

    if (!user) {
        return { error: 'Unauthorized' }
    }

    const post = await db.posts.findById(postId)
    if (post.authorId !== user.id) {
        return { error: 'Forbidden' }
    }

    await db.posts.delete(postId)
    revalidatePath('/blog')
    return { success: true }
}

Server Actions vs API Routes

Server Actions API Routes
Use case Form mutations, simple data ops Public API, webhooks, external consumers
Type safety End-to-end TypeScript Manual (or tRPC)
Client code No fetch/axios needed fetch() or axios
Caching/revalidation Built-in with revalidatePath Manual
External access No (server-only) Yes (public URL)
File uploads FormData native Need middleware

Use Server Actions for mutations in your own Next.js app. Use API Routes for endpoints consumed by external services, mobile apps, or if you need a public API.

Server Actions eliminate an entire layer of boilerplate for typical web app mutations. The form -> validation -> database -> cache invalidation -> redirect flow that used to need an API route, manual fetch, error handling on both sides now fits in a single file.

K
Founder & Technical Lead, Innovibe

Building software for 15+ years. Passionate about AI, system design, and shipping things that work.

Frequently asked questions

When should I use Server Components vs Client Components?+

Server Components are the default — use them for data fetching, static content, and anything that doesn't need interactivity. Only switch to Client Components when you need browser APIs, state, effects, or event handlers. The less JS you ship to the browser, the faster your site.

Why is my Next.js build so slow?+

Usually it's TypeScript type-checking or a large dependency. Try `NEXT_TELEMETRY_DISABLED=1 next build` with `--profile` and look at what's taking time. Splitting large data files and enabling turborepo are the two biggest wins.

Should I use the App Router or Pages Router for a new project?+

App Router for new projects, full stop. Pages Router still works fine but receives fewer improvements. The learning curve for App Router is steep for one week, then you never want to go back.

Does Innovibe build this kind of thing for clients?+

Yes — this is exactly what we do day-to-day for clients across BC and Canada. If you'd rather have us build and maintain it than implement it yourself, reach out.

How do I decide whether to build this in-house or hire an agency?+

Build in-house if your team has the skills and bandwidth and this is core to your product. Hire out if it's infrastructure, if speed matters, or if the expertise gap would take months to close. We're biased, obviously — but we'll tell you honestly when in-house makes more sense.

Building something with AI?

We scope and ship AI features quickly. Let's talk.

Start a Conversation