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 theactionprop 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.
Create an
actions.tsfile to house our server actions. A common pattern is to place this at the root of yoursrc/appdirectory or within asrc/app/actionsfolder. Let’s usesrc/app/actions.ts.touch src/app/actions.tsAdd 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; }Create a new route for todos:
src/app/todos/page.tsx.mkdir src/app/todos touch src/app/todos/page.tsxAdd 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> ); }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.
- Navigate to
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):
Integrate
useFormStatefor Error Handling:- Uncomment the
useFormStateline insrc/app/todos/page.tsxand wrap the<form>with it. - Modify the
addTodoserver action insrc/app/actions.tsto return an object like{ error: 'Message' }if thetodoTextis empty, and{ success: true }otherwise. - Display
state.errorprominently near the form if it exists. - Hint: The
useFormStatehook takes two arguments:(action, initialState).actionshould be your Server Action, andinitialStateis the initial value for the state returned byuseFormState.
- Uncomment the
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
useOptimisticin the React documentation for guidance).
- For a more advanced challenge, explore
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.tsfile should be placed at the root of yoursrc/appdirectory (orsrcif not using theappfolder), alongsidelayout.tsxandpage.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.tsdefaults to the Edge Runtime, which is optimized for speed and low latency, but has limitations (e.g., no Node.js specific APIs likefsor 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.
Create a
proxy.tsfile atsrc/app/proxy.ts.touch src/app/proxy.tsAdd 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
proxyfunction receives aNextRequestobject. - We check for an
authenticatedcookie. In a real app, this would involve validating a session token or JWT. - If a user tries to access
/todoswithout theauthenticatedcookie, they are redirected to the homepage with a message. - The
config.matcherproperty specifies which paths thisproxyshould run on.'/((?!_next/static|_next/image|favicon.ico).*)'is a common pattern to match all requests except for static assets.
- The
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
localhostwith:- Name:
authenticated - Value:
true
- Name:
- Now, try navigating to
http://localhost:3000/todosagain. You should be able to access the page! - Delete the cookie and try again to confirm the protection.
- Navigate to
Exercises/Mini-Challenges (Proxy):
Add a
/_loginroute forproxy.tsredirects:- Create a simple
src/app/_login/page.tsxthat says “Please log in.” - Modify
src/app/proxy.tsto redirect to/_logininstead of/if a user is unauthenticated and tries to access/todos. - Test this new redirect behavior.
- Create a simple
Add a simple logging proxy:
- Modify
src/app/proxy.tstoconsole.logtherequest.nextUrl.pathnameand the user-agent (request.headers.get('user-agent')) for every incoming request. - Check your terminal where
npm run devis running to see the logs.
- Modify
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 undersrc/app(commonlysrc/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 standardRequestobject and must return aResponseobject (fromnext/serveror 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.
Create an API route folder:
src/app/api/products.mkdir -p src/app/api/products touch src/app/api/products/route.tsAdd 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 } ); } }Test the API Route:
- Start your development server (
npm run dev). - GET request: Open
http://localhost:3000/api/productsin your browser. You should see a JSON array of products. - POST request (using
curlor Postman/Insomnia):- Open a new terminal.
- Run this
curlcommand to add a new product:You should receive a JSON response with the newly created product and statuscurl -X POST \ -H "Content-Type: application/json" \ -d '{"name": "USB-C Hub", "price": 29.99}' \ http://localhost:3000/api/products201. - Now, refresh
http://localhost:3000/api/productsin your browser. The new product should appear in the list (sinceapiProductsis a simple in-memory array for this example, it will reset on server restart).
- Start your development server (
Example: Dynamic API Routes
Just like pages, API routes can have dynamic segments.
Create a dynamic route folder:
src/app/api/products/[id].mkdir src/app/api/products/[id] touch src/app/api/products/[id]/route.tsAdd 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 }); }Test the Dynamic API Routes:
- GET a single product: Open
http://localhost:3000/api/products/prod_1in your browser. - DELETE a product (using
curl):Checkcurl -X DELETE http://localhost:3000/api/products/prod_2http://localhost:3000/api/productsto confirm it’s gone. - UPDATE a product (using
curl):Checkcurl -X PUT \ -H "Content-Type: application/json" \ -d '{"name": "Updated Wireless Headphones", "price": 109.99}' \ http://localhost:3000/api/products/prod_1http://localhost:3000/api/products/prod_1to confirm the update.
- GET a single product: Open
Exercises/Mini-Challenges (API Routes):
Add Error Handling for Invalid ID:
- In
src/app/api/products/[id]/route.ts, if theidinparamsis 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).
- In
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. Therequest.nextUrl.searchParams.get('search')will be useful here. - If no
searchquery is provided, return all products.
- In
Create an
_index.tsfile insidesrc/app/apito 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/awaitand the extendedfetch()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(), andredirect()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,useFormStatefor handling return values (errors/success messages), anduseOptimisticfor immediate UI updates.
API Routes (route.ts)
- Files named
route.ts(or.js) within theappdirectory. - 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
Requestobjects and returnResponseobjects, 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/appdirectory. - 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, withrevalidateTag()now requiring acacheLifeprofile for SWR behavior, andupdateTag()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.