Logo

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>
  );
}