Next.js API Routes and Server Actions
Introduction
Next.js provides powerful server-side capabilities through API Routes and Server Actions. These features allow developers to build full-stack applications without setting up separate backend services. This guide explores both approaches, their use cases, and how to implement them effectively in your Next.js projects.
API Routes Basics
API Routes in Next.js allow you to create API endpoints as part of your Next.js application. They live in the app/api
directory (in the App Router) or pages/api
directory (in the Pages Router) and are processed entirely on the server.
Creating Your First API Route
Here's a simple example of an API route in the App Router:
// app/api/hello/route.ts import { NextResponse } from 'next/server'; export async function GET() { return NextResponse.json({ message: 'Hello, Next.js!' }); }
HTTP Methods
API Routes support all HTTP methods. You can export functions corresponding to the HTTP methods you want to handle:
// app/api/users/route.ts import { NextResponse } from 'next/server'; export async function GET() { // Fetch users from database return NextResponse.json({ users: ['John', 'Jane'] }); } export async function POST(request: Request) { const body = await request.json(); // Create a new user return NextResponse.json({ created: true, user: body.name }); } export async function PUT(request: Request) { const body = await request.json(); // Update a user return NextResponse.json({ updated: true, user: body.name }); } export async function DELETE(request: Request) { const { searchParams } = new URL(request.url); const id = searchParams.get('id'); // Delete a user return NextResponse.json({ deleted: true, id }); }
Advanced API Routes Techniques
Dynamic API Routes
You can create dynamic API routes to handle variable parameters:
// app/api/users/[id]/route.ts import { NextResponse } from 'next/server'; export async function GET( request: Request, { params }: { params: { id: string } } ) { const id = params.id; // Fetch user with id return NextResponse.json({ id, name: `User ${id}` }); }
API Middleware
You can use middleware to process requests before they reach your API routes:
// middleware.ts import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; export function middleware(request: NextRequest) { // Check for authentication token const token = request.headers.get('authorization'); if (!token) { return NextResponse.json( { error: 'Authentication required' }, { status: 401 } ); } return NextResponse.next(); } export const config = { matcher: '/api/:path*', };
Server Actions Introduction
Server Actions, introduced in Next.js 13.4, allow you to run server-side code directly from client components. They provide a way to mutate data on the server without creating API endpoints.
Enabling Server Actions
To use Server Actions, you need to enable the experimental feature in your next.config.js
:
// next.config.js /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverActions: true, }, }; module.exports = nextConfig;
Note: As of Next.js 14, Server Actions are stable and no longer require the experimental flag.
Server Actions vs API Routes
Here's a comparison of Server Actions and API Routes:
Server Actions Advantages
- Simplified data mutations without explicit API endpoints
- Progressive enhancement with native form support
- Reduced client-server code boundary
- Automatic optimistic updates with useOptimistic hook
- Built-in error handling and validation
API Routes Advantages
- More explicit REST-like architecture
- Can be consumed by external clients
- Better for building public APIs
- Familiar pattern for developers coming from other frameworks
Form Handling with Server Actions
Server Actions excel at handling form submissions. Here's an example of a form that uses a Server Action:
// app/components/ContactForm.tsx 'use client'; import { useFormStatus } from 'react-dom'; function SubmitButton() { const { pending } = useFormStatus(); return ( <button type="submit" disabled={pending}> {pending ? 'Submitting...' : 'Submit'} </button> ); } export default function ContactForm() { return ( <form action={async (formData) => { 'use server'; const name = formData.get('name'); const email = formData.get('email'); const message = formData.get('message'); // Save to database console.log({ name, email, message }); // Could redirect or show success message }}> <div> <label htmlFor="name">Name</label> <input type="text" id="name" name="name" required /> </div> <div> <label htmlFor="email">Email</label> <input type="email" id="email" name="email" required /> </div> <div> <label htmlFor="message">Message</label> <textarea id="message" name="message" required></textarea> </div> <SubmitButton /> </form> ); }
Data Mutations with Server Actions
Server Actions can be defined in separate files and imported into components. This approach is useful for organizing your code:
// app/actions.ts 'use server'; import { revalidatePath } from 'next/cache'; import { redirect } from 'next/navigation'; export async function createTodo(formData: FormData) { const title = formData.get('title') as string; // Validate if (!title || title.length < 3) { return { error: 'Title must be at least 3 characters' }; } // Save to database await db.todo.create({ data: { title } }); // Revalidate the todos list revalidatePath('/todos'); // Redirect to the todos page redirect('/todos'); }
Then use it in a component:
// app/todos/new/page.tsx import { createTodo } from '@/app/actions'; export default function NewTodo() { return ( <form action={createTodo}> <input type="text" name="title" placeholder="Todo title" /> <button type="submit">Create Todo</button> </form> ); }
Security Considerations
CSRF Protection
Next.js automatically protects Server Actions against Cross-Site Request Forgery (CSRF) attacks. This protection is built into the framework.
Input Validation
Always validate user input on the server side. You can use libraries like Zod for validation:
// app/actions.ts 'use server'; import { z } from 'zod'; const UserSchema = z.object({ name: z.string().min(2), email: z.string().email(), age: z.number().min(18).optional(), }); export async function createUser(formData: FormData) { const rawData = { name: formData.get('name'), email: formData.get('email'), age: formData.get('age') ? Number(formData.get('age')) : undefined, }; // Validate the data const result = UserSchema.safeParse(rawData); if (!result.success) { // Return validation errors return { errors: result.error.flatten().fieldErrors }; } // Process valid data const validData = result.data; // Save to database, etc. return { success: true }; }
Best Practices
When to Use API Routes
- Building a public API for third-party consumption
- Creating webhook endpoints
- When you need explicit REST endpoints
- For compatibility with existing API clients
When to Use Server Actions
- Form submissions with progressive enhancement
- Data mutations from client components
- When you want to reduce client-server code boundaries
- For optimistic UI updates
Code Organization
Keep your Server Actions organized by:
- Grouping related actions in domain-specific files
- Using a consistent naming convention
- Separating data access logic from action handlers
- Creating reusable validation utilities
Real-World Examples
Authentication System
A login form using Server Actions:
// app/actions/auth.ts 'use server'; import { cookies } from 'next/headers'; import { redirect } from 'next/navigation'; import { createSession } from '@/lib/auth'; export async function login(formData: FormData) { const email = formData.get('email') as string; const password = formData.get('password') as string; try { // Authenticate user const user = await authenticateUser(email, password); if (!user) { return { error: 'Invalid credentials' }; } // Create session const session = await createSession(user.id); // Set session cookie cookies().set('session', session.id, { httpOnly: true, secure: process.env.NODE_ENV === 'production', maxAge: 60 * 60 * 24 * 7, // 1 week path: '/', }); redirect('/dashboard'); } catch (error) { return { error: 'Authentication failed' }; } }
E-commerce Cart
Adding items to a cart with optimistic updates:
// app/components/AddToCart.tsx 'use client'; import { useOptimistic } from 'react'; import { addToCart } from '@/app/actions/cart'; export default function AddToCart({ product, cartItems }) { const [optimisticCart, addOptimisticItem] = useOptimistic( cartItems, (state, newItem) => [...state, newItem] ); async function addItem(formData: FormData) { const productId = formData.get('productId') as string; // Optimistically update the UI addOptimisticItem({ id: 'temp-id', productId, quantity: 1, product, }); // Actually perform the server action await addToCart(formData); } return ( <div> <form action={addItem}> <input type="hidden" name="productId" value={product.id} /> <button type="submit">Add to Cart</button> </form> <div> <h3>Cart Items</h3> <ul> {optimisticCart.map((item) => ( <li key={item.id}> {item.product.name} - Quantity: {item.quantity} </li> ))} </ul> </div> </div> ); }