JP
JP Codes
Change theme
Next.js

Next.js Server Actions vs React Fetch

A clear comparison of Next.js Server Actions and traditional client-side fetch requests — covering performance, security, bundle size, and when to reach for each approach.

James Platt

James Platt

November 12, 2025 · 12 min read

12 min read
Server Actions vs React Fetch

Quick Definitions

React fetch request — a client-side data request using fetch() or a library like Axios. The request happens in the browser after the page loads, typically inside a useEffect hook or an event handler.

Next.js Server Actions — introduced in Next.js 13.4+, Server Actions let you run server-side code (database writes, API calls, mutations) directly from your React components or forms — without a separate API route or a client-side fetch call.

Key Benefits of Server Actions

Category Server Actions Client Fetch
Performance Runs on server — no client JS for fetching. Smaller bundle, faster load. Runs in browser — waits for hydration. More JS shipped.
Security API keys and DB credentials stay on the server. Requires exposed endpoint — risk of leaking sensitive logic.
Simplicity No separate API route needed. Requires defining and maintaining API routes.
Caching Integrates with Next.js revalidatePath / revalidateTag. Manual — you manage caching (SWR, React Query, etc.).
Forms Works with native <form action={fn}> — no JS needed for submission. Requires custom JS to intercept and handle form submits.

Side-by-Side Example: Adding a Todo

Using a Server Action

// app/page.tsx
'use server'

export async function addTodo(formData: FormData) {
  const todo = formData.get('todo') as string
  await db.todo.create({ data: { text: todo } })
}

// In your component:
<form action={addTodo}>
  <input name="todo" type="text" />
  <button type="submit">Add</button>
</form>

No API route. No fetch() call. No client JS for the form submission logic.

Using Client Fetch

// pages/api/add-todo.ts
export default async function handler(req, res) {
  const { todo } = req.body
  await db.todo.create({ data: { text: todo } })
  res.status(200).json({ success: true })
}

// In your component:
async function handleSubmit(e) {
  e.preventDefault()
  await fetch('/api/add-todo', {
    method: 'POST',
    body: JSON.stringify({ todo }),
  })
}

You need an API route. You ship more JS. Harder to integrate with server-side rendering and caching.

Full CRUD with Server Actions

// app/actions.ts
'use server'

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

export async function addTodo(formData: FormData) {
  const text = formData.get('text') as string
  if (!text.trim()) return
  await db.addTodo(text)
  revalidatePath('/')
}

export async function toggleTodo(id: number) {
  await db.toggleTodo(id)
  revalidatePath('/')
}

export async function deleteTodo(id: number) {
  await db.deleteTodo(id)
  revalidatePath('/')
}
// app/page.tsx
import { db } from '@/lib/db'
import { addTodo, toggleTodo, deleteTodo } from './actions'

export default async function Page() {
  const todos = await db.getTodos()

  return (
    <div className="mx-auto max-w-md p-8">
      <form action={addTodo} className="flex gap-2">
        <input name="text" className="flex-1 rounded border px-3 py-2" />
        <button className="bg-blue-600 px-4 py-2 text-white rounded">Add</button>
      </form>

      <ul className="mt-6 space-y-2">
        {todos.map(todo => (
          <li key={todo.id} className="flex items-center justify-between border px-3 py-2 rounded">
            <form action={async () => toggleTodo(todo.id)}>
              <button className={todo.done ? 'line-through text-gray-400' : ''}>
                {todo.text}
              </button>
            </form>
            <form action={async () => deleteTodo(todo.id)}>
              <button className="text-red-500">✕</button>
            </form>
          </li>
        ))}
      </ul>
    </div>
  )
}

No API routes. No client JS. Revalidation happens automatically after each action.

Optimistic UI with useOptimistic

Server Actions can be combined with React's useOptimistic hook to make the UI feel instant — the UI updates immediately, and the server action runs in the background:

'use client'
import { useOptimistic, useTransition } from 'react'
import { addTodo } from './actions'

export default function TodoList({ todos }) {
  const [optimisticTodos, addOptimistic] = useOptimistic(
    todos,
    (state, newTodo) => [...state, newTodo]
  )
  const [isPending, startTransition] = useTransition()

  async function handleAdd(formData) {
    const text = formData.get('text')
    addOptimistic({ id: Date.now(), text, done: false })
    startTransition(async () => {
      await addTodo(formData)
    })
  }

  return (
    <form action={handleAdd}>
      <input name="text" disabled={isPending} />
      <button disabled={isPending}>{isPending ? 'Adding...' : 'Add'}</button>
      <ul>
        {optimisticTodos.map(t => <li key={t.id}>{t.text}</li>)}
      </ul>
    </form>
  )
}

When to Use Which

Use Server Actions for:

Use Client Fetch when:

Server Actions = Secure, faster, simpler server-side data handling.
Client Fetch = Dynamic, client-driven interactions after page load.

Tags

James Platt

James Platt

Web Developer

James is a Microsoft-qualified C# .NET developer with extensive experience building robust, data-rich web applications. He writes about web development, software architecture, and best practices at JP Codes.

Read more about James

More articles