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:
- CRUD operations that interact with your database
- Secure server-side logic (anything involving secrets)
- Form submissions
- Reducing client JS bundle size
Use Client Fetch when:
- You need real-time or frequently updating data after page load
- You're fetching from external APIs dynamically based on user interaction
- You're building a non-Next.js (plain React) application
Server Actions = Secure, faster, simpler server-side data handling.
Client Fetch = Dynamic, client-driven interactions after page load.
Tags