Published on

Next.js Server Actions - The End of API Routes?

Authors

Introduction

Next.js Server Actions let you run server-side code directly from React components — no API routes needed. Mutate your database, send emails, update sessions — all from a function call in your UI.

In 2026, Server Actions have matured into the recommended way to handle form submissions and data mutations in Next.js.

What Are Server Actions?

Server Actions are async functions that run on the server but can be called from Client Components. They're defined with the 'use server' directive.

Client Component → calls Server Action → runs on server → returns result

No fetch, no API route, no JSON parsing — just a function call.

Your First Server Action

app/actions.ts
'use server'

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

export async function createPost(title: string, content: string) {
  await db.post.create({
    data: { title, content }
  })
  revalidatePath('/blog')  // Refresh the blog page cache
}
app/blog/new/page.tsx
'use client'

import { createPost } from '@/app/actions'

export default function NewPostPage() {
  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault()
    const formData = new FormData(e.currentTarget)
    await createPost(
      formData.get('title') as string,
      formData.get('content') as string
    )
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="title" placeholder="Post title" />
      <textarea name="content" placeholder="Content" />
      <button type="submit">Create Post</button>
    </form>
  )
}

Using Server Actions with Forms Directly

The cleanest pattern — bind a Server Action directly to a <form>:

app/contact/page.tsx
import { submitContact } from '@/app/actions'

export default function ContactPage() {
  return (
    <form action={submitContact}>
      <input name="name" required />
      <input name="email" type="email" required />
      <textarea name="message" required />
      <button type="submit">Send</button>
    </form>
  )
}
app/actions.ts
'use server'

export async function submitContact(formData: FormData) {
  const name = formData.get('name') as string
  const email = formData.get('email') as string
  const message = formData.get('message') as string

  await sendEmail({ name, email, message })
  redirect('/contact/success')
}

No preventDefault, no useState for loading — it just works!

useFormState and useFormStatus (React 19)

For better UX with loading and error states:

app/blog/new/page.tsx
'use client'

import { useFormState, useFormStatus } from 'react-dom'
import { createPost } from '@/app/actions'

function SubmitButton() {
  const { pending } = useFormStatus()
  return (
    <button type="submit" disabled={pending}>
      {pending ? 'Creating...' : 'Create Post'}
    </button>
  )
}

export default function NewPostPage() {
  const [state, formAction] = useFormState(createPost, null)

  return (
    <form action={formAction}>
      {state?.error && <p className="error">{state.error}</p>}
      {state?.success && <p className="success">Post created!</p>}
      <input name="title" required />
      <textarea name="content" required />
      <SubmitButton />
    </form>
  )
}
app/actions.ts
'use server'

export async function createPost(prevState: any, formData: FormData) {
  try {
    const title = formData.get('title') as string
    if (!title) return { error: 'Title is required' }

    await db.post.create({ data: { title } })
    revalidatePath('/blog')
    return { success: true }
  } catch (e) {
    return { error: 'Something went wrong' }
  }
}

Inline Server Actions in Server Components

You can define server actions inline inside Server Components:

app/todos/page.tsx
import { db } from '@/lib/db'
import { revalidatePath } from 'next/cache'

export default async function TodosPage() {
  const todos = await db.todo.findAll()

  async function addTodo(formData: FormData) {
    'use server'  // Inline 'use server' directive
    const text = formData.get('text') as string
    await db.todo.create({ data: { text } })
    revalidatePath('/todos')
  }

  async function deleteTodo(id: string) {
    'use server'
    await db.todo.delete({ where: { id } })
    revalidatePath('/todos')
  }

  return (
    <div>
      <form action={addTodo}>
        <input name="text" placeholder="New todo" />
        <button type="submit">Add</button>
      </form>

      <ul>
        {todos.map(todo => (
          <li key={todo.id}>
            {todo.text}
            <form action={deleteTodo.bind(null, todo.id)}>
              <button type="submit">Delete</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  )
}

Validation with Zod

Always validate inputs server-side:

app/actions.ts
'use server'

import { z } from 'zod'

const CreateUserSchema = z.object({
  name: z.string().min(2, 'Name must be at least 2 characters'),
  email: z.string().email('Invalid email address'),
  age: z.number().min(18, 'Must be 18+'),
})

export async function createUser(formData: FormData) {
  const parsed = CreateUserSchema.safeParse({
    name: formData.get('name'),
    email: formData.get('email'),
    age: Number(formData.get('age')),
  })

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

  await db.user.create({ data: parsed.data })
  revalidatePath('/users')
  return { success: true }
}

Authentication in Server Actions

app/actions.ts
'use server'

import { auth } from '@/lib/auth'
import { redirect } from 'next/navigation'

export async function deletePost(postId: string) {
  const session = await auth()

  // Guard: must be logged in
  if (!session) redirect('/login')

  // Guard: must own the post
  const post = await db.post.findById(postId)
  if (post.authorId !== session.user.id) {
    throw new Error('Unauthorized')
  }

  await db.post.delete({ where: { id: postId } })
  revalidatePath('/blog')
}

Optimistic Updates with useOptimistic

app/todos/page.tsx
'use client'

import { useOptimistic } from 'react'
import { toggleTodo } from '@/app/actions'

export default function TodoList({ todos }) {
  const [optimisticTodos, updateOptimistic] = useOptimistic(
    todos,
    (state, todoId) =>
      state.map(t => t.id === todoId ? { ...t, done: !t.done } : t)
  )

  return (
    <ul>
      {optimisticTodos.map(todo => (
        <li
          key={todo.id}
          style={{ textDecoration: todo.done ? 'line-through' : 'none' }}
          onClick={async () => {
            updateOptimistic(todo.id)  // Instant UI update
            await toggleTodo(todo.id) // Actual server call
          }}
        >
          {todo.text}
        </li>
      ))}
    </ul>
  )
}

Conclusion

Server Actions fundamentally simplify Next.js development. Database mutations, form submissions, and server-side logic can now live right alongside your UI — no separate API route, no fetch call, no JSON handling. For full-stack Next.js apps in 2026, Server Actions are the modern, recommended way to handle data mutations.