Intermediate Concepts: Server Actions, Proxy, and API Routes

5. Intermediate Concepts: Server Actions, Proxy, and API Routes

Next.js empowers you to build full-stack applications by providing robust server-side capabilities. In this chapter, we’ll explore three key features: Server Actions (for direct server-side data mutations), the proxy.ts file (which replaced middleware.ts for network boundary concerns), and API Routes (for building traditional API endpoints).

5.1 Server Actions: Direct Server-Side Mutations

Server Actions are a groundbreaking feature in Next.js (part of React 19’s capabilities) that allow you to define server-side functions and invoke them directly from your Client Components or Server Components. This significantly simplifies data mutations and form handling by eliminating the need to manually create API endpoints for every mutation.

How Server Actions Work

  • 'use server' Directive: Any function or file marked with 'use server' at the top becomes a Server Action. These functions run exclusively on the server.
  • Direct Invocation: You can call these server functions directly from your React components (both Client and Server Components) as if they were regular JavaScript functions. Next.js handles the network communication behind the scenes.
  • Form Integration: Server Actions integrate seamlessly with HTML <form> elements, allowing you to attach a server function directly to the action prop of a form.
  • Data Revalidation & Redirects: Server Actions can directly revalidate the Next.js cache (revalidatePath, revalidateTag) and perform redirects (redirect), enabling powerful data management workflows.

Example: A “Todo” App with Server Actions

Let’s build a simple Todo application that adds and deletes todos using Server Actions.

  1. Create an actions.ts file to house our server actions. A common pattern is to place this at the root of your src/app directory or within a src/app/actions folder. Let’s use src/app/actions.ts.

    touch src/app/actions.ts
    
  2. Add the following code to src/app/actions.ts:

    // src/app/actions.ts
    'use server'; // This directive is crucial: it marks this file as server-only code
    
    import { revalidatePath } from 'next/cache'; // For revalidating data
    import { redirect } from 'next/navigation'; // For programmatic redirects
    
    // Simulate a database of todos
    let todos: { id: number; text: string; completed: boolean }[] = [
      { id: 1, text: 'Learn Next.js Server Actions', completed: false },
      { id: 2, text: 'Build a Next.js App', completed: false },
    ];
    let nextId = 3; // For unique IDs
    
    export async function addTodo(formData: FormData) {
      // Simulate network delay
      await new Promise(resolve => setTimeout(resolve, 500));
    
      const todoText = formData.get('todoText') as string;
    
      if (!todoText || todoText.trim() === '') {
        console.error('Todo text cannot be empty');
        // You could return an error object here to the client component
        return { error: 'Todo text cannot be empty.' };
      }
    
      todos.push({
        id: nextId++,
        text: todoText.trim(),
        completed: false,
      });
    
      console.log('Todo added:', todoText);
    
      // Revalidate the path to update UI with new todo
      // This tells Next.js to re-fetch data for the '/' route when this action completes.
      revalidatePath('/todos'); // Assumes we will have a /todos page
      return { success: true };
    }
    
    export async function deleteTodo(id: number) {
      // Simulate network delay
      await new Promise(resolve => setTimeout(resolve, 300));
    
      const initialLength = todos.length;
      todos = todos.filter((todo) => todo.id !== id);
    
      if (todos.length === initialLength) {
        console.error('Todo not found for deletion:', id);
        return { error: `Todo with ID ${id} not found.` };
      }
    
      console.log('Todo deleted:', id);
      revalidatePath('/todos');
      return { success: true };
    }
    
    export async function completeTodo(id: number, completed: boolean) {
      // Simulate network delay
      await new Promise(resolve => setTimeout(resolve, 300));
    
      const todoIndex = todos.findIndex(todo => todo.id === id);
      if (todoIndex === -1) {
          console.error('Todo not found for completion:', id);
          return { error: `Todo with ID ${id} not found.` };
      }
      todos[todoIndex].completed = completed;
      console.log(`Todo ${id} set to completed: ${completed}`);
      revalidatePath('/todos');
      return { success: true };
    }
    
    // Example of a Server Action that performs a redirect
    export async function goToHome() {
      redirect('/'); // Redirects to the homepage
    }
    
    // Helper to get todos for the Server Component
    export async function getTodos() {
      await new Promise(resolve => setTimeout(resolve, 200)); // Simulate fetch time
      return todos;
    }
    
  3. Create a new route for todos: src/app/todos/page.tsx.

    mkdir src/app/todos
    touch src/app/todos/page.tsx
    
  4. Add the following content to src/app/todos/page.tsx:

    // src/app/todos/page.tsx
    import { addTodo, deleteTodo, completeTodo, getTodos, goToHome } from '@/app/actions';
    import { Suspense } from 'react';
    import { useFormStatus, useFormState } from 'react-dom'; // Client-side hooks for form status
    
    // Client Component for the "Add Todo" button
    function SubmitButton() {
      // useFormStatus can only be used in a Client Component that is a descendant of a <form action="...">
      const { pending } = useFormStatus();
    
      return (
        <button
          type="submit"
          aria-disabled={pending}
          className="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded disabled:opacity-50 disabled:cursor-not-allowed"
          style={{ marginLeft: '10px' }}
        >
          {pending ? 'Adding...' : 'Add Todo'}
        </button>
      );
    }
    
    // Client Component for individual todo items (to handle client-side interactions)
    function TodoItem({ todo }: { todo: { id: number; text: string; completed: boolean } }) {
      const handleCompleteToggle = async () => {
        await completeTodo(todo.id, !todo.completed);
      };
    
      const handleDelete = async () => {
        if (confirm(`Are you sure you want to delete "${todo.text}"?`)) {
          await deleteTodo(todo.id);
        }
      };
    
      return (
        <li className="flex items-center justify-between p-3 bg-white rounded-md shadow-sm mb-2">
          <div className="flex items-center">
            <input
              type="checkbox"
              checked={todo.completed}
              onChange={handleCompleteToggle}
              className="mr-3 w-5 h-5 accent-blue-600"
            />
            <span className={todo.completed ? 'line-through text-gray-500' : 'text-gray-800'}>
              {todo.text}
            </span>
          </div>
          <button
            onClick={handleDelete}
            className="bg-red-500 hover:bg-red-600 text-white text-sm py-1 px-3 rounded"
          >
            Delete
          </button>
        </li>
      );
    }
    
    // Main Server Component for the Todos Page
    export default async function TodosPage() {
      const todos = await getTodos();
    
      // useFormState allows you to handle return values from a server action
      // const [state, formAction] = useFormState(addTodo, { error: null });
    
      return (
        <main className="container mx-auto p-6 max-w-lg bg-white shadow-lg rounded-lg mt-10">
          <h1 className="text-4xl font-extrabold text-gray-900 mb-6 text-center">Todo List</h1>
    
          <form action={addTodo} className="flex mb-8">
            <input
              type="text"
              name="todoText"
              placeholder="What needs to be done?"
              required
              className="flex-grow border border-gray-300 p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
            />
            <SubmitButton />
          </form>
    
          {/* Optional: Error display if using useFormState for addTodo */}
          {/* {state?.error && <p className="text-red-500 mb-4">{state.error}</p>} */}
    
    
          <h2 className="text-2xl font-semibold text-gray-700 mb-4">Your Todos</h2>
          {todos.length === 0 ? (
            <p className="text-gray-600 text-center">No todos yet. Add one above!</p>
          ) : (
            <ul className="list-none p-0">
              {todos.map((todo) => (
                <TodoItem key={todo.id} todo={todo} />
              ))}
            </ul>
          )}
    
          <div className="mt-8 text-center">
            <form action={goToHome}>
              <button
                type="submit"
                className="bg-gray-200 hover:bg-gray-300 text-gray-800 font-bold py-2 px-4 rounded"
              >
                Go to Home
              </button>
            </form>
          </div>
        </main>
      );
    }
    
  5. Save all files. Make sure your development server is running.

    • Navigate to http://localhost:3000/todos.
    • Try adding new todos using the form.
    • Try deleting existing todos.
    • Toggle the completion status.

Notice how the UI updates instantly after each action, thanks to revalidatePath. The form submission and deleteTodo / completeTodo functions are directly invoking server-side code without explicit API calls in your client-side JavaScript. This is the power of Server Actions!

Exercises/Mini-Challenges (Server Actions):

  1. Integrate useFormState for Error Handling:

    • Uncomment the useFormState line in src/app/todos/page.tsx and wrap the <form> with it.
    • Modify the addTodo server action in src/app/actions.ts to return an object like { error: 'Message' } if the todoText is empty, and { success: true } otherwise.
    • Display state.error prominently near the form if it exists.
    • Hint: The useFormState hook takes two arguments: (action, initialState). action should be your Server Action, and initialState is the initial value for the state returned by useFormState.
  2. Add Optimistic UI:

    • For a more advanced challenge, explore useOptimistic (a React 19 hook) to make the “Add Todo” experience even smoother.
    • The goal is for a new todo to appear in the list immediately after typing and submitting, even before the server response, and then reconcile if there’s an error. (This is a more complex task, research useOptimistic in the React documentation for guidance).

5.2 Proxy (proxy.ts - Formerly Middleware)

In Next.js, the proxy.ts file (formerly middleware.ts for network boundary concerns) allows you to run code before a request is completed. This is executed at the “Edge” (a globally distributed network of servers), making it incredibly fast. It’s ideal for tasks that need to run before your page or API route logic.

Next.js 16 (the latest version at the time of writing) explicitly renames middleware.ts to proxy.ts for handling network boundaries, clarifying its purpose.

How proxy.ts Works

  • Location: The proxy.ts file should be placed at the root of your src/app directory (or src if not using the app folder), alongside layout.tsx and page.tsx.
  • Execution: It runs for every request matching its scope before any page or API route handler.
  • Request/Response Modification: It can modify incoming requests (NextRequest) and outgoing responses (NextResponse), perform redirects, or rewrite URLs.
  • Edge Runtime: proxy.ts defaults to the Edge Runtime, which is optimized for speed and low latency, but has limitations (e.g., no Node.js specific APIs like fs or direct database access).

Example: Basic Authentication with proxy.ts

Let’s protect our /todos route so only authenticated users can access it. We’ll simulate authentication with a cookie.

  1. Create a proxy.ts file at src/app/proxy.ts.

    touch src/app/proxy.ts
    
  2. Add the following content to src/app/proxy.ts:

    // src/app/proxy.ts
    import { NextResponse } from 'next/server';
    import type { NextRequest } from 'next/server';
    
    // This function can run at the Edge.
    // It's recommended to keep it as small and fast as possible.
    export default function proxy(request: NextRequest) {
      const isAuthenticated = request.cookies.has('authenticated'); // Check for our custom auth cookie
      const path = request.nextUrl.pathname;
    
      // Public paths that don't require authentication
      const publicPaths = ['/', '/blog', '/products', '/users', '/jokes', '/api'];
    
      // If it's a public path, or an API route (which might have its own auth)
      if (publicPaths.some(p => path.startsWith(p))) {
        return NextResponse.next(); // Allow access
      }
    
      // If it's a protected path (e.g., /todos) and not authenticated
      if (!isAuthenticated && path.startsWith('/todos')) {
        // Redirect to a login page (we don't have one, so redirect to home for now)
        const url = new URL('/', request.url);
        url.searchParams.set('message', 'Please log in to view todos.');
        return NextResponse.redirect(url);
      }
    
      // If authenticated or not a protected route, proceed
      return NextResponse.next();
    }
    
    // You can configure which paths the middleware runs on
    // The matcher runs on every request.
    // If a path matches, then the middleware function will be executed.
    // For more complex matchers, use a regex array: ['/todos/:path*', '/dashboard/:path*']
    export const config = {
      matcher: [
        '/((?!_next/static|_next/image|favicon.ico).*)', // Match all paths except static files
      ],
    };
    

    Explanation:

    • The proxy function receives a NextRequest object.
    • We check for an authenticated cookie. In a real app, this would involve validating a session token or JWT.
    • If a user tries to access /todos without the authenticated cookie, they are redirected to the homepage with a message.
    • The config.matcher property specifies which paths this proxy should run on. '/((?!_next/static|_next/image|favicon.ico).*)' is a common pattern to match all requests except for static assets.
  3. Test the Proxy:

    • Navigate to http://localhost:3000/todos. You should be redirected to the homepage (/).
    • Open your browser’s developer tools (F12) and go to the “Application” or “Storage” tab. Under “Cookies”, set a cookie for localhost with:
      • Name: authenticated
      • Value: true
    • Now, try navigating to http://localhost:3000/todos again. You should be able to access the page!
    • Delete the cookie and try again to confirm the protection.

Exercises/Mini-Challenges (Proxy):

  1. Add a /_login route for proxy.ts redirects:

    • Create a simple src/app/_login/page.tsx that says “Please log in.”
    • Modify src/app/proxy.ts to redirect to /_login instead of / if a user is unauthenticated and tries to access /todos.
    • Test this new redirect behavior.
  2. Add a simple logging proxy:

    • Modify src/app/proxy.ts to console.log the request.nextUrl.pathname and the user-agent (request.headers.get('user-agent')) for every incoming request.
    • Check your terminal where npm run dev is running to see the logs.

5.3 API Routes (route.ts): Building Traditional Endpoints

While Server Components and Server Actions handle most server-side data needs, API Routes (defined using route.ts files in the App Router) are still essential for:

  • Building a public API consumed by third-party applications or other frontends (mobile apps, other websites).
  • Implementing complex backend logic that might not directly relate to a UI mutation.
  • Integrating with webhook services.
  • Acting as a proxy for external services to hide API keys or transform data.

How API Routes Work

  • File-System Based: Similar to pages, API routes are defined by creating route.ts (or .js) files within any folder under src/app (commonly src/app/api).
  • HTTP Method Exports: Instead of a default export for a component, you export functions named after HTTP methods (GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS).
  • Web API Request/Response: These functions receive a standard Request object and must return a Response object (from next/server or native Web APIs).
  • Edge or Node.js Runtime: By default, API Routes run on the Node.js runtime. You can opt into the Edge Runtime for specific routes by exporting export const runtime = 'edge';.

Example: A Simple Products API

Let’s create an API route for our products, allowing us to fetch all products and add new ones.

  1. Create an API route folder: src/app/api/products.

    mkdir -p src/app/api/products
    touch src/app/api/products/route.ts
    
  2. Add the following content to src/app/api/products/route.ts:

    // src/app/api/products/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    
    // In a real app, this would be a database interaction
    let apiProducts: { id: string; name: string; price: number }[] = [
      { id: 'prod_1', name: 'Wireless Headphones', price: 99.99 },
      { id: 'prod_2', name: 'Mechanical Keyboard', price: 79.50 },
      { id: 'prod_3', name: 'Gaming Mouse', price: 45.00 },
    ];
    
    let nextProductId = apiProducts.length + 1;
    
    // Handle GET requests (e.g., GET /api/products)
    export async function GET(request: NextRequest) {
      // You can access query parameters: request.nextUrl.searchParams.get('category');
      const searchParams = request.nextUrl.searchParams;
      const category = searchParams.get('category');
    
      let filteredProducts = apiProducts;
      if (category) {
        filteredProducts = apiProducts.filter(p => p.name.toLowerCase().includes(category.toLowerCase()));
      }
    
      return NextResponse.json(filteredProducts, { status: 200 });
    }
    
    // Handle POST requests (e.g., POST /api/products)
    export async function POST(request: NextRequest) {
      try {
        const body = await request.json(); // Parse the request body
        const { name, price } = body;
    
        if (!name || typeof price !== 'number' || price <= 0) {
          return NextResponse.json(
            { error: 'Name and a valid price are required.' },
            { status: 400 }
          );
        }
    
        const newProduct = {
          id: `prod_${nextProductId++}`,
          name,
          price: parseFloat(price.toFixed(2)),
        };
        apiProducts.push(newProduct);
    
        return NextResponse.json(newProduct, { status: 201 }); // 201 Created
      } catch (error) {
        console.error('Error creating product:', error);
        return NextResponse.json(
          { error: 'Failed to create product. Invalid JSON or server error.' },
          { status: 500 }
        );
      }
    }
    
  3. Test the API Route:

    • Start your development server (npm run dev).
    • GET request: Open http://localhost:3000/api/products in your browser. You should see a JSON array of products.
    • POST request (using curl or Postman/Insomnia):
      • Open a new terminal.
      • Run this curl command to add a new product:
        curl -X POST \
        -H "Content-Type: application/json" \
        -d '{"name": "USB-C Hub", "price": 29.99}' \
        http://localhost:3000/api/products
        
        You should receive a JSON response with the newly created product and status 201.
      • Now, refresh http://localhost:3000/api/products in your browser. The new product should appear in the list (since apiProducts is a simple in-memory array for this example, it will reset on server restart).

Example: Dynamic API Routes

Just like pages, API routes can have dynamic segments.

  1. Create a dynamic route folder: src/app/api/products/[id].

    mkdir src/app/api/products/[id]
    touch src/app/api/products/[id]/route.ts
    
  2. Add content to src/app/api/products/[id]/route.ts:

    // src/app/api/products/[id]/route.ts
    import { NextRequest, NextResponse } from 'next/server';
    
    // For simplicity, reusing the in-memory array from /api/products/route.ts
    // In a real app, you would fetch from a database.
    let apiProducts: { id: string; name: string; price: number }[] = [
      { id: 'prod_1', name: 'Wireless Headphones', price: 99.99 },
      { id: 'prod_2', name: 'Mechanical Keyboard', price: 79.50 },
      { id: 'prod_3', name: 'Gaming Mouse', price: 45.00 },
    ];
    
    
    interface DynamicRouteParams {
      params: {
        id: string;
      };
    }
    
    // Handle GET requests for a single product (e.g., GET /api/products/prod_1)
    export async function GET(
      request: NextRequest,
      { params }: DynamicRouteParams
    ) {
      const { id } = params;
      const product = apiProducts.find(p => p.id === id);
    
      if (!product) {
        return NextResponse.json({ error: 'Product not found' }, { status: 404 });
      }
    
      return NextResponse.json(product, { status: 200 });
    }
    
    // Handle DELETE requests for a single product (e.g., DELETE /api/products/prod_1)
    export async function DELETE(
      request: NextRequest,
      { params }: DynamicRouteParams
    ) {
      const { id } = params;
      const initialLength = apiProducts.length;
      apiProducts = apiProducts.filter(p => p.id !== id);
    
      if (apiProducts.length === initialLength) {
        return NextResponse.json({ error: 'Product not found' }, { status: 404 });
      }
    
      return new NextResponse(null, { status: 204 }); // 204 No Content for successful deletion
    }
    
    // Handle PUT/PATCH for updating a product (e.g., PUT /api/products/prod_1)
    export async function PUT(
      request: NextRequest,
      { params }: DynamicRouteParams
    ) {
      const { id } = params;
      const body = await request.json();
      const { name, price } = body;
    
      const productIndex = apiProducts.findIndex(p => p.id === id);
    
      if (productIndex === -1) {
        return NextResponse.json({ error: 'Product not found' }, { status: 404 });
      }
    
      apiProducts[productIndex] = {
        ...apiProducts[productIndex],
        name: name || apiProducts[productIndex].name,
        price: typeof price === 'number' && price > 0 ? parseFloat(price.toFixed(2)) : apiProducts[productIndex].price,
      };
    
      return NextResponse.json(apiProducts[productIndex], { status: 200 });
    }
    
  3. Test the Dynamic API Routes:

    • GET a single product: Open http://localhost:3000/api/products/prod_1 in your browser.
    • DELETE a product (using curl):
      curl -X DELETE http://localhost:3000/api/products/prod_2
      
      Check http://localhost:3000/api/products to confirm it’s gone.
    • UPDATE a product (using curl):
      curl -X PUT \
      -H "Content-Type: application/json" \
      -d '{"name": "Updated Wireless Headphones", "price": 109.99}' \
      http://localhost:3000/api/products/prod_1
      
      Check http://localhost:3000/api/products/prod_1 to confirm the update.

Exercises/Mini-Challenges (API Routes):

  1. Add Error Handling for Invalid ID:

    • In src/app/api/products/[id]/route.ts, if the id in params is not found, ensure a proper 404 response is returned for GET, PUT, and DELETE requests. (You’ve already started this, but review and ensure consistent error messages).
  2. Implement Query Filtering:

    • In src/app/api/products/route.ts (the GET all products route), add functionality to filter products by a query parameter, e.g., /api/products?search=keyboard. The request.nextUrl.searchParams.get('search') will be useful here.
    • If no search query is provided, return all products.
  3. Create an _index.ts file inside src/app/api to get more details about server components, API routes, and Server Actions from the latest blog post by vercel. And summarize what it provides to end users in bullet points

Next.js provides a comprehensive set of tools for building highly interactive and performant full-stack applications. Understanding when to use Server Components for initial renders, Server Actions for mutations, Proxy for edge-based request handling, and API Routes for traditional backend endpoints is key to leveraging its full power.


With Next.js 16 (and recent updates in the App Router), the lines between frontend and backend are increasingly blurred, offering powerful patterns for full-stack development. Here’s a summary of how Server Components, API Routes, and Server Actions fit together and their latest enhancements:

Server Components (RSC)

  • Default in the App Router.
  • Rendered exclusively on the server, generating static HTML and JSON.
  • Key benefit: Reduces client-side JavaScript bundle size, improving initial page load and time to interactive.
  • Data Fetching: Can directly fetch data from databases or internal APIs using async/await and the extended fetch() API.
  • No client-side interactivity: Cannot use useState, useEffect, browser APIs directly.
  • Composition: Can import and render Client Components.

Server Actions

  • Server-side functions defined with 'use server' directive.
  • Key benefit: Enable direct data mutations and form submissions from client or server components without explicit API endpoints. This simplifies form handling boilerplate.
  • Integration: Seamlessly integrates with HTML <form action={serverAction}>.
  • Data Management: Can revalidatePath(), revalidateTag(), and redirect() directly after mutations, tightly integrating with Next.js’s caching mechanisms.
  • Runs on server: Accesses server-side resources securely.
  • React Hooks: Use useFormStatus (client-side hook) for pending states, useFormState for handling return values (errors/success messages), and useOptimistic for immediate UI updates.

API Routes (route.ts)

  • Files named route.ts (or .js) within the app directory.
  • Key benefit: Expose traditional HTTP endpoints (GET, POST, PUT, DELETE, etc.) for external consumption (e.g., third-party clients, mobile apps, webhooks).
  • Web APIs: Handle Request objects and return Response objects, aligning with web standards.
  • Flexibility: Ideal for complex backend logic, proxying external services, custom authentication flows, and webhook handlers.
  • Dynamic Segments: Support dynamic routes (e.g., app/api/users/[id]/route.ts).
  • Runtime: Defaults to Node.js runtime, but can be opted into Edge Runtime for specific routes.

Proxy (proxy.ts - formerly middleware.ts)

  • Placed at the root of the src/app directory.
  • Key benefit: Executes code before a request reaches its destination (page, API route, or Server Action).
  • Edge Runtime: Primarily runs at the Edge, making it extremely fast for tasks like authentication checks, redirects, URL rewrites, and A/B testing.
  • Network Boundary: Clearly defines logic that runs at the network edge, separate from application-specific server logic.
  • Limitations: Cannot perform expensive computations or direct database queries due to Edge runtime constraints.

Overall Next.js 16 Enhancements related to these concepts:

  • Cache Components ("use cache" directive): A new model for explicit caching of pages, components, and functions, leveraging Partial Pre-Rendering (PPR). This makes caching opt-in and provides more granular control than implicit caching.
  • Next.js Devtools MCP: Model Context Protocol integration for AI-assisted debugging, providing contextual insights into routing, caching, and rendering behavior.
  • Improved Caching APIs (revalidateTag(), updateTag()): Refined functions for more explicit control over cache revalidation, with revalidateTag() now requiring a cacheLife profile for SWR behavior, and updateTag() for read-your-writes semantics in Server Actions.

In essence, Next.js provides a spectrum of server-side tools. Server Components are for rendering UI with data on the server. Server Actions are for handling interactive mutations directly from components. API Routes are for building full-fledged backend endpoints. And proxy.ts acts as a request interceptor at the network edge. Understanding when and how to combine these efficiently is key to building high-performance, maintainable Next.js applications.