web development is in constant flux. There was a time when everything was rendered on the server (PHP, Ruby on Rails). Then, we move everything to the client (SPAs with React, Vue). Now, with the advent of React Server Components (RSC) and Next.js App Router, we're finding a powerful middle ground: the best of both worlds.
And the crown jewel of this new era in the React ecosystem are Server Actions.
With the release of Next.js 15, Server Actions are no longer experimental and have become the recommended standard for handling data mutations. In this massive guide, we will explore every detail of this feature, from the basics to advanced security and UX standards. If you're still writing pages/api/submit.ts to process forms, prepare to retire a lot of boilerplate code.
What Are Server Actions, Anyway?
In simple terms, Server Actions are asynchronous functions that run on the server, but can be invoked directly from client or server components.
Previously, to submit a form, the flow was:
- Create a form component on the client (
"use client"). - Create a
useStatestate for the inputs. - Create a function
onSubmitthat does afetch('/api/submit', ...). - Create an API Route file (
pages/api/submit.ts) to receive the request, validate and save it in the database. - Handle errors, loading states and data revalidation manually.
With Server Actions, the flow is:
- Create a function
asyncthat saves in the bank. - Pass this function to prop
actionof<form>.
End. Next.js takes care of communication, serialization, and execution. It's Remote Procedure Call (RPC) done right for the web.
Initial Configuration in Next.js 15
In Next.js 15, Server Actions are already enabled by default. You do not need to change the next.config.js.
The convention is simple:
- To define actions that can be imported into Client components, create a file with the
"use server"directive at the top. - For inline actions in Server components, add
"use server"inside the function.
Basic Example: The "Hello World" of Actions
Let's create an action to save a blog post. Create a src/app/actions.ts file:
// src/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 // Validação básica (em produção use Zod!) if (!title || !content) { throw new Error('Campos obrigatórios faltando') } // Interação direta com o banco (sem API Routes!) await db.post.create({ data: { title, content } }) // A mágica: atualiza a UI instantaneamente revalidatePath('/blog') }
And in your component:
// src/components/create-post-form.tsx import { createPost } from '@/app/actions' export function CreatePostForm() { return ( <form action={createPost} className="p-4 border rounded"> <input name="title" placeholder="Título" className="border p-2 mb-2 w-full" /> <textarea name="content" placeholder="Conteúdo" className="border p-2 w-full" /> <button type="submit" className="bg-blue-500 text-white p-2 rounded"> Salvar Post </button> </form> ) }
Note the absence of JavaScript on the client for submission. This form works even if the user disables JS in the browser (Progressive Enhancement), although in modern applications we rarely depend on this.
Charging State Management (useFormStatus)
Bad UX is clicking "Save" and not getting feedback. Since we are using the native HTML prop action, we do not have a useState(loading) manual. React provides us with the useFormStatus hook for this.
Note: useFormStatus must be used in a component rendered within <form>.
// src/components/submit-button.tsx 'use client' import { useFormStatus } from 'react-dom' export function SubmitButton() { const { pending } = useFormStatus() return ( <button type="submit" disabled={pending} className="bg-blue-500 disabled:bg-gray-400 text-white p-2 rounded flex items-center gap-2" > {pending ? 'Salvando...' : 'Salvar Post'} {pending && <Spinner />} </button> ) }
Now just use <SubmitButton /> inside the form in the previous example.
Error Handling and Feedback (useActionState)
What if database fails? Or validation? Simply throwing an error (throw new Error) is not the best UX. We want to return error messages to the form.
To do this, we use hook useActionState (previously useFormState in experimental React). It allows the Server Action to return a value that is updated on the client.
Refactoring our action:
// src/app/actions.ts 'use server' export type FormState = { message: string; errors?: { title?: string[]; content?: string[]; }; } export async function createPostSafely(prevState: FormState, formData: FormData): Promise<FormState> { // Simulação de validação com Zod const validatedFields = schema.safeParse({ title: formData.get('title'), content: formData.get('content'), }); if (!validatedFields.success) { return { message: 'Erro de validação', errors: validatedFields.error.flatten().fieldErrors } } try { await db.post.create({ data: validatedFields.data }) } catch (e) { return { message: 'Erro ao salvar no banco de dados' } } revalidatePath('/blog') return { message: 'Post criado com sucesso!' } }
And in the client component:
'use client' import { useActionState } from 'react' import { createPostSafely } from '@/app/actions' const initialState = { message: '', errors: {} } export function AdvancedForm() { const [state, formAction] = useActionState(createPostSafely, initialState) return ( <form action={formAction}> {state.message && <p className="text-red-500">{state.message}</p>} <input name="title" /> {state.errors?.title && <p className="text-red-500 text-sm">{state.errors.title[0]}</p>} {/* ... restante do form ... */} </form> ) }
Cache Revalidation: The Power of revalidatePath
In the old model, after a mutation, you had to re-fetch the data manually or use libraries like React Query or SWR to invalidate caches.
In Next.js, this is built in. Function revalidatePath(path) clears the cache for that specific route. On the next visit (or immediately, if it's a SPA navigation), Next.js fetches the fresh data from the server.
There is also revalidateTag(tag), which gives you granular control if you are using fetch with (fetch(url, { next: { tags: ['posts'] } })) tags.
Optimistic Updates (useOptimistic)
For applications that appear "instant", we don't want to wait for the server to respond to update the UI. If I add an item to the list, I want to see it there now.
The hook useOptimistic allows this in an elegant way.
'use client' import { useOptimistic } from 'react' export function PostList({ posts }: { posts: Post[] }) { const [optimisticPosts, addOptimisticPost] = useOptimistic( posts, (state, newPost: Post) => [newPost, ...state] ); async function action(formData: FormData) { const title = formData.get('title') as string; // Atualiza a UI imediatamente addOptimisticPost({ id: Math.random(), title, content: '...' }); // Chama a Server Action real await createPost(formData); } return ( <div> <form action={action}>...</form> <ul> {optimisticPosts.map(post => ( <li key={post.id}>{post.title}</li> ))} </ul> </div> ) }
If the Server Action fails, React automatically rolls back the optimistic state to the previous real state. It's robust and magical.
Security: The Elephant in the Room
Many developers look at "use server" and think, "Wait, am I exposing my database to the client?"
No. The Server Action code is never sent to the browser. Only the "signature" of the function (an internal Next.js reference URL) is exposed. However, it is crucial to treat Server Actions as public entry points to your API.
Mandatory Security Checklist:
-
Authentication: Always check who is calling the action. Don't assume the user is logged in just because the "Save" button was visible.
import { auth } from '@/auth' // Auth.js ou Clerk export async function deletePost(id: string) { const session = await auth() if (!session?.user) throw new Error('Não autorizado') // ... } -
Authorization: The user is logged in, but can he *delete this post?
const post = await db.post.findUnique({ where: { id } }) if (post.authorId !== session.user.id) throw new Error('Proibido') -
Input Validation: Never trust
FormDataor passed arguments. Use Zod to ensure the data is in the correct format.
Server Actions vs API Routes
After all, are API Routes dead?
Not exactly, but its use has decreased drastically.
Use Server Actions when:
- You are dealing with form mutations.
- You want to call a server function from an event on the client (
onClick). - You want TypeScript end-to-end typing without generating SDKs.
Use API Routes (Route Handlers) when:
- You need to expose a public [REST API47 to third parties (webhooks, mobile apps).
- You need specific features of the HTTP protocol that React abstractions hide (e.g. streaming custom binaries, complex headers).
Conclusion
Next.js 15 solidifies Server Actions as one of the most productive changes in recent web development. They eliminate the middle layer of "glue code" (API routes, fetchers, state managers) and allow you to focus on what matters: business logic and user interface.
By combining Server Actions with useActionState, useOptimistic and validation with Zod, you create robust, secure applications with a world-class user experience, writing a fraction of the code you would have written 3 years ago.
The future is server-side, but the experience is client-side. And Server Actions are the bridge between them.
Have you already migrated your forms to Server Actions? What was the biggest challenge? Leave your comment!
Also read
- Server Actions in Next.js: mutations without maintaining an API just for that
- Next.js App Router: The Guide to Thinking Server by Default
- Cache and streaming in Next.js: performance became an architectural decision
- Internationalization Best Practices (i18n) in React and Next.js in 2025
- What Are React Server Components and Why Logic is Moving Back to the Server
- Building a Custom Headless CMS with Strapi and Next.js
