Back to Blog
2024-11-05
Next.jsReact

Mastering Next.js 15 Server Actions

Content generated by AI Agent (Gemini 2.0 Flash)

Mastering Next.js 15 Server Actions

Server Actions are one of the most transformative features in the React ecosystem, and with Next.js 15, they have matured into a robust solution for handling data mutations. If you've been building web applications for a while, you know the drill: creating API routes, handling form submissions on the client, managing loading states, and manually revalidating data. Server Actions aim to simplify this entire pipeline by allowing you to call server-side functions directly from your client components.

In this deep dive, we will explore how to leverage Server Actions effectively, covering progressive enhancement, type safety, and optimistic UI updates.

What Are Server Actions?

At their core, Server Actions are asynchronous functions that are executed on the server. They can be invoked in Server Components, Client Components, or directly from HTML forms.

terminal
// app/actions.ts 'use server' export async function createTodo(formData: FormData) { const title = formData.get('title') await db.todo.create({ data: { title } }) revalidatePath('/todos') }

The 'use server' directive is the magic switch. It tells the bundler that this function should never be shipped to the client but can be invoked by it via a secure RPC call.

Progressive Enhancement

One of the strongest arguments for using Server Actions is their support for progressive enhancement. When used with the action prop on a <form>, they work before JavaScript has loaded.

terminal
import { createTodo } from './actions' export function TodoForm() { return ( <form action={createTodo}> <input name="title" type="text" required /> <button type="submit">Add Todo</button> </form> ) }

If the user has disabled JavaScript, or is on a slow connection where hydration hasn't finished, this form still works. The browser submits a standard POST request, Next.js handles it on the server, executes the action, revalidates the path, and returns the simplified HTML.

Handling Pending States

While progressive enhancement is great, we usually want to provide a richer experience for users with JavaScript. The useFormStatus hook is perfect for this. It allows us to show a loading spinner or disable the submit button while the action is executing.

terminal
'use client' import { useFormStatus } from 'react-dom' function SubmitButton() { const { pending } = useFormStatus() return ( <button disabled={pending} type="submit" className="bg-blue-500 text-white p-2 rounded"> {pending ? 'Adding...' : 'Add Todo'} </button> ) }

Note that useFormStatus must be used in a child component of the <form> element to access the status context.

Optimistic Updates

Waiting for a server roundtrip can feel sluggish. Users expect instant feedback. We can use the useOptimistic hook to update the UI immediately, assuming the server action will succeed.

terminal
'use client' import { useOptimistic } from 'react' import { createTodo } from './actions' export function TodoList({ todos }) { const [optimisticTodos, addOptimisticTodo] = useOptimistic( todos, (state, newTodo) => [...state, newTodo] ) async function action(formData: FormData) { const title = formData.get('title') addOptimisticTodo({ id: Math.random(), title, pending: true }) await createTodo(formData) } return ( <div> <form action={action}> <input name="title" /> <button type="submit">Add</button> </form> <ul> {optimisticTodos.map((t) => ( <li key={t.id} style={{ opacity: t.pending ? 0.5 : 1 }}> {t.title} </li> ))} </ul> </div> ) }

When the user submits, the addOptimisticTodo function updates the state locally. The UI reflects the new item instantly. In the background, createTodo runs. When it finishes and revalidatePath triggers a fresh server render, the optimisticTodos state is discarded in favor of the real data provided by the server.

Security Considerations

Since Server Actions are public endpoints (under the hood), you must treat them with the same security scrutiny as REST or GraphQL APIs.

  1. Authentication: Always check if the user is authenticated at the start of your action.
  2. Authorization: Verify the user has permission to perform the specific mutation.
  3. Validation: Never trust FormData. Use a library like Zod to validate inputs.
terminal
'use server' import { z } from 'zod' import { auth } from './auth' const schema = z.object({ title: z.string().min(3), }) export async function createTodo(prevState: any, formData: FormData) { const session = await auth() if (!session) return { message: 'Unauthorized' } const parsed = schema.safeParse({ title: formData.get('title'), }) if (!parsed.success) { return { errors: parsed.error.flatten().fieldErrors } } // Database logic here }

Conclusion

Next.js 15 Server Actions fundamentally change how we think about data mutations. They blur the line between server and client in a way that feels natural, while reinforcing web fundamentals like forms and HTTP. By combining them with hooks like useOptimistic and useFormStatus, we can build applications that are robust, accessible, and incredibly fast.