Next.js

Server Actions in Next.js 15

What Server Actions are, when they're the right tool over route handlers, how to validate input and handle errors, and the security considerations that ...

Server Actions are async functions that run on the server, triggered directly from the client, without you writing a route handler. They look like ordinary TypeScript functions, but Next.js handles the HTTP plumbing under the hood. A form submission or a button click calls the function, the server runs it, and the result comes back.

The appeal is that they remove a class of boilerplate. You don't write a POST endpoint, define a request schema, wire up a fetch call on the client, handle the response format, and then update state. You write a function.

The 'use server' Directive

Mark a function as a Server Action by adding 'use server' at the top of the function body, or at the top of a file to make every export in it an action.

// lib/actions.ts
'use server'

export async function createNote(formData: FormData) {
    const title = formData.get('title') as string
    await db.notes.create({ data: { title } })
}

That's the whole directive. Next.js compiles this into a server endpoint and gives the client a reference it can call. You never see the URL.

A single-file approach works for small projects. For larger codebases, keeping actions in a dedicated lib/actions/ directory (one file per domain) keeps things easier to follow.

Calling from Forms

The simplest use case is a form. Pass the action to the action prop and you're done:

// app/notes/new/page.tsx
import { createNote } from '@/lib/actions'

export default function NewNotePage() {
    return (
        <form action={createNote}>
            <input name="title" type="text" required />
            <button type="submit">Create</button>
        </form>
    )
}

This works without JavaScript in the browser. The form posts to the server, the action runs, and Next.js redirects or revalidates as needed. Progressive enhancement, for free.

Calling from Client Components

When you need more control (optimistic updates, loading states, conditional logic) you call the action from a Client Component using useActionState from React:

'use client'

import { useActionState } from 'react'
import { createNote } from '@/lib/actions'

type ActionState = { error?: string; success?: boolean }

export function NoteForm() {
    const [state, formAction, isPending] = useActionState<ActionState, FormData>(
        createNote,
        {}
    )

    return (
        <form action={formAction}>
            <input name="title" type="text" required />
            {state.error && <p className="text-red-500">{state.error}</p>}
            <button type="submit" disabled={isPending}>
                {isPending ? 'Saving...' : 'Create'}
            </button>
        </form>
    )
}

useActionState gives you the previous return value from the action (state), a wrapped version of the action to pass to the form (formAction), and a boolean that's true while the action is in flight (isPending). The action signature changes slightly: it now receives the previous state as its first argument.

Validating Input

Never trust what the client sends. Validate on the server before you touch a database or external service. Zod is the cleanest way to do this:

'use server'

import { z } from 'zod'
import { revalidatePath } from 'next/cache'

const NoteSchema = z.object({
    title: z.string().min(1).max(200),
})

type ActionState = { error?: string; success?: boolean }

export async function createNote(
    _prev: ActionState,
    formData: FormData
): Promise<ActionState> {
    const result = NoteSchema.safeParse({
        title: formData.get('title'),
    })

    if (!result.success) {
        return { error: result.error.issues[0].message }
    }

    await db.notes.create({ data: result.data })
    revalidatePath('/notes')

    return { success: true }
}

safeParse is the key. It returns a result object instead of throwing, so you can return a structured error state back to the client rather than crashing the action.

Error Handling

For expected errors (validation failures, business rule violations) return an error state from the action. The client reads state.error and renders it.

For unexpected errors (database going down, third-party API failing) let them throw. React will catch them at the nearest error boundary. In the browser, useActionState surfaces them as a thrown error that error.tsx can catch. On a Server Component calling an action directly, the same boundary applies.

export async function createNote(
    _prev: ActionState,
    formData: FormData
): Promise<ActionState> {
    const result = NoteSchema.safeParse({ title: formData.get('title') })

    if (!result.success) {
        // Expected: return it, don't throw
        return { error: result.error.issues[0].message }
    }

    // Unexpected failures throw and surface at the error boundary
    await db.notes.create({ data: result.data })
    revalidatePath('/notes')

    return { success: true }
}

Keep the distinction clean: return errors you know about, throw errors you don't.

Security Considerations

A few things worth knowing before you ship Server Actions to production.

CSRF is handled automatically. Next.js validates an origin header on every Server Action request. You don't need to manage CSRF tokens yourself.

Don't trust the client. Just because an action is triggered from a form you control doesn't mean the data coming in is safe. A user can craft any request. Always validate and authorise on the server, every time.

Close over secrets carefully. If a Server Action closes over a value at build time, Next.js encrypts it. But if you're passing server-only data to a client component that then calls an action, think about whether that path leaks anything it shouldn't.

Authorise inside the action. Don't rely on the UI to hide or disable an action from unauthorised users. Check the session inside the action itself:

'use server'

import { getSession } from '@/lib/auth'

export async function deleteNote(id: string) {
    const session = await getSession()

    if (!session?.user) {
        throw new Error('Unauthorised')
    }

    await db.notes.delete({ where: { id, userId: session.user.id } })
    revalidatePath('/notes')
}

The userId filter in the query matters as much as the session check. You want the database query to only affect records owned by the current user, not just any record with that id.

Server Actions vs Route Handlers

Route handlers (files named route.ts) still have their place. Use them when you need a public HTTP API consumed by third parties, when you need fine-grained control over response headers and status codes, or when you're building a webhook endpoint. For everything that's internal to your own app (form submissions, mutations triggered by user interaction, server-side operations tied to your own UI) Server Actions are cleaner and involve less wiring.

The mental model is: route handlers are for HTTP, Server Actions are for your app's mutations.

← Older
The Metadata API and SEO in Next.js
Newer →
Route Handlers vs Server Actions

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.