Next.js as a backend for your frontend
Put a Next.js app in front of other services: a public Route Handler API, rewrites, parallel calls with timeouts, data shaped for the UI, keys on the server.
- 01Put Next.js in front of other servicesCall several services from a Server Component or a Route Handler, in parallel, with a timeout and a fallback for each.
- 02Open an API, and proxy safelyVersion, validate, identify and rate-limit a public Route Handler, and know what a rewrite passes on and when to choose one.
- 03Decide where code belongsShape service data for the UI, keep keys on the server, and tell when a separate backend is the better home.
The tea shop in front of its services
The tea shop's Next.js app isn't the only software the shop runs. The warehouse team runs a stock service that knows how many boxes of each tea are left, and another team runs a reviews service. Each is an HTTP API on its own server: send GET /stock/mint, get JSON back. The page for Mint tea needs both.
The browser could call both services itself. Then the stock service's key would have to be in the page, where anyone can read it; the visitor would wait for two more requests after the page arrived; and the browser would get every field the services send, reviewers' emails included. Instead, the Next.js app calls them, on the server, and sends the browser one page made for it. A server that stands between one frontend and the services behind it, built for that frontend, is called a backend for frontend, or BFF. Next.js's docs have a guide for using it this way, and point out that its server features are an API layer that anyone can reach, not a full replacement for a backend. Here is the tea page:
app/teas/[id]/page.tsximport { notFound } from "next/navigation";import { Suspense } from "react";import { getTeaSummary } from "@/lib/tea-summary"; export default function TeaPage({ params }: PageProps<"/teas/[id]">) { return ( <main> <h1>Tea details</h1> <Suspense fallback={<p>Loading…</p>}> <Details params={params} /> </Suspense> </main> );} async function Details({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const summary = await getTeaSummary(id); if (!summary) notFound(); const { stock, reviews } = summary; return ( <> <p>{stock ? `${stock.boxes} boxes in stock` : "Stock unknown right now."}</p> {!reviews ? ( <p>Reviews are unavailable right now.</p> ) : reviews.latest ? ( <p> Rated {reviews.average} from {reviews.count} reviews. {reviews.latest.author}: {reviews.latest.text} </p> ) : ( <p>No reviews yet.</p> )} </> );}12 boxes in stock
Rated 4.5 from 2 reviews. Asha: Fresh and bright.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
This is a real Next.js 16.4.3 project, built with next build and run with next start; what it sent back was recorded. In app/teas/[id]/page.tsx, lines 9 to 11 wrap Details in Suspense, so the page's heading can be sent before the services answer. Line 17 reads the tea's id from the address, and line 18 asks getTeaSummary in lib/tea-summary.ts for both services' answers. Line 19 handles a tea that doesn't exist, and lines 23 to 32 show each answer, or a sentence for when it's missing. The Browser tab shows 12 boxes in stock and Rated 4.5 from 2 reviews. Asha: Fresh and bright. Pick /teas/green above the page: 4 boxes in stock and No reviews yet. The Terminal tab lists ◐ /teas/[id]: a static shell with the details streamed in for each request.
/teas/oolong is a tea neither service knows: both answered 404, so getTeaSummary returned null and line 19 called notFound(). The heading had already been sent, so the status stayed 200, and Next.js added <meta name="robots" content="noindex"> to the HTML, as Not-found and access errors recorded for streamed pages.
The services in these recordings are a stand-in: one small server that plays both, whose address the recorder gave the app in STOCK_API_URL and REVIEWS_API_URL, the way a deployment sets them. It kept every request it received, with some of its headers. During the three visits, it received these:
| Request the service received | Its status | The headers it came with |
|---|---|---|
GET /stock/mint | 200 | host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings |
GET /reviews/mint | 200 | host: 127.0.0.1:<port> |
GET /stock/green | 200 | host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings |
GET /reviews/green | 200 | host: 127.0.0.1:<port> |
GET /stock/oolong | 404 | host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings |
GET /reviews/oolong | 404 | host: 127.0.0.1:<port> |
Two requests for each page, both from the shop's server: each stock request carried the shop's key in x-api-key, and the reviews requests carried none. Nothing reached the services while the app was being built. The browser only ever asked the shop.
A guest doesn't walk into the kitchen and then the bar. One waiter takes the order, collects the food and the drinks, and brings back one plate, arranged for that table. If the bar is slow, the waiter waits only so long, then brings the food and says the drinks aren't available.
- In real life: A guest who orders from one waiter
- In Next.js: The browser, which asks the shop for one page
- In real life: The waiter, who goes to the kitchen and to the bar
- In Next.js: The Next.js app, which calls the stock and reviews services
- In real life: The kitchen's door, which guests never go through
- In Next.js: The services' addresses and keys, which stay on the server
- In real life: One plate, arranged for this guest
- In Next.js: One page, with only the fields it shows
- In real life: Waiting only so long for the bar
- In Next.js: A timeout on each service call
A backend for frontend is a server that stands between one frontend and the services behind it. In Next.js, Server Components, Route Handlers and Server Actions call the services on the server: in parallel, with a timeout for each, keeping the keys there, and sending the browser only the data it needs. It doesn't replace the services; it shapes them for one app.
Route Handlers as a public API
The shop's phone app needs the same summary, as JSON. A Route Handler, a route.ts file, answers it. Unlike a page's server code, it's an API that other programs depend on: anyone who knows the address can call it, and old versions of the phone app keep calling it for months. So the shop puts the checks every endpoint needs in one module. The data here is the public catalog; a phone user's own data, like their orders, would need them to sign in, with a session or a token checked in the data access layer (Authorization), like on the website.
import "server-only"; // The apps that call this API. A client id says which app it is. It ships inside the app,// so anyone can copy it: it's for counting and coarse limits, never for deciding who sees what.const clients = new Set(["phone-app"]); // A stand-in rate limit: 4 requests a minute for each client id, counted in this server's memory.const calls = new Map<string, { start: number; count: number }>();const minute = 60_000; // Recording fixture: FIXED_NOW freezes the clock, so the recording never depends on timing. Never set it in production.const now = () => (process.env.FIXED_NOW ? Number(process.env.FIXED_NOW) : Date.now()); export function limitClient(request: Request) { const client = request.headers.get("x-client-id"); if (!client || !clients.has(client)) return Response.json({ error: "Send a known x-client-id" }, { status: 400 }); const time = now(); const current = calls.get(client); if (!current || time - current.start >= minute) { calls.set(client, { start: time, count: 1 }); return null; } current.count += 1; if (current.count <= 4) return null; const retryAfter = Math.ceil((current.start + minute - time) / 1000); return Response.json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": String(retryAfter) } });} export const teaId = /^[a-z-]{1,30}$/; - Line 5 lists the apps the shop knows. Line 15 reads the
x-client-idheader, and line 16 answers400when it's missing or unknown. A client id says which app is calling, but it ships inside the app, so anyone can copy it and send it. It isn't a credential: it's a label for counting and coarse limits, never for deciding who sees what. - Lines 17 to 26 are a rate limit: at most 4 calls a minute for each client id. Line 19 starts a new minute, line 25 works out how many seconds of that minute are left, and line 26 answers
429, "too many requests", with that number inretry-after. Since anyone can change their client id, it only slows careless callers down. It counts in this server's memory, a stand-in: with several copies of the app, each would count on its own (Securing Server Actions said the same), so use your host's rate limiting or a shared store. - Line 12 reads the time. With
FIXED_NOWset, the clock stands still: that's a recording fixture, set in this example's.envso the recording never depends on how fast the requests were sent. It's never set in production. - Line 29,
teaId, is a regular expression for a valid id: 1 to 30 lowercase letters and dashes.
app/api/v1/teas/[id]/route.tsimport { limitClient, teaId } from "@/lib/public-api";import { getTeaSummary } from "@/lib/tea-summary"; export async function GET(request: Request, { params }: RouteContext<"/api/v1/teas/[id]">) { const refused = limitClient(request); if (refused) return refused; const { id } = await params; if (!teaId.test(id)) return Response.json({ error: "Not a tea id" }, { status: 400 }); const summary = await getTeaSummary(id); if (!summary) return Response.json({ error: "No such tea" }, { status: 404 }); return Response.json(summary);}GET /api/v1/teas/mint200
- Sent with the request
x-client-idphone-app
- Response headers
varyrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchcontent-typeapplication/json
The body that arrived, as it arrived
{"stock":{"boxes":12},"reviews":{"average":4.5,"count":2,"latest":{"author":"Asha","text":"Fresh and bright."}}}In app/api/v1/teas/[id]/route.ts, lines 5 and 6 refuse a call before anything else. Line 8 answers 400 for an id that isn't one, before any service is called. Line 9 calls getTeaSummary, the same function the page uses, and line 10 answers 404 when both services say there's no such tea, instead of an empty 200. The Network tab shows the six recorded calls, in order:
- From the phone app:
200, with the stock and the reviews as JSON. - Without a client id:
400,{"error":"Send a known x-client-id"}. - With the id
MINT:400,{"error":"Not a tea id"}. - With the id
oolong, which the shop doesn't sell:404,{"error":"No such tea"}. /api/v2/teas/mint:200,{"inStock":true,"rating":4.5}, the smaller shape that the new phone app asks for.- A fifth call in the same minute:
429, withretry-after: 60, because the frozen clock left the whole minute.
Each row is one thing a public API has to decide, what the shop does, and what the recording showed:
| Concern | What the shop does | What the recording showed |
|---|---|---|
| Versions | Two folders, api/v1 and api/v2. When an answer's shape must change, add a version and keep the old one for phone apps that haven't updated. | /api/v2/teas/mint answered a smaller shape; /api/v1/teas/mint still answers the old one. |
| Checking what was sent | teaId on line 29 of lib/public-api.ts allows 1 to 30 lowercase letters and dashes. A well-formed id for a tea neither service knows answers 404, not an empty 200. | /api/v1/teas/MINT answered 400 before any service was called; /api/v1/teas/oolong answered 404. |
| Knowing which app calls | Each app sends an x-client-id. It ships inside the app, so anyone can copy it: it's a label for counting and coarse limits, not a credential. | A call without one answered 400. A person's own data needs them to sign in, with a session or token checked in the data access layer. |
| Rate limiting | At most 4 calls a minute for each client id, then 429 with retry-after: the seconds left in the minute. This stand-in counts in one server's memory, and a caller can change its client id. | The fifth call in a minute answered 429 with retry-after: 60; the recording froze the clock. For real limits, use your host's rate limiting or a shared store. |
| Other sites' pages | No CORS headers: the phone app isn't a browser, so CORS doesn't apply to it. | If a partner's page called it from a browser, the Route Handlers lesson's CORS headers would be needed too. |
One thing the page never does: fetch /api/v1/teas/mint. A Server Component calls getTeaSummary directly, as Fetching data in Server Components recorded; the Route Handler is for callers that can't import your functions. And error answers say little on purpose: Next.js's guide warns against sending internal details in them.
Proxying with rewrites
Sometimes you want one of your own addresses to answer with another service's answer, unchanged. The reviews team has built a widget that fetches reviews in the browser. CORS, cross-origin resource sharing, is the browser's rule for whether a page from another origin may read a response; it decides nothing about who may call. Calling the shop's own domain avoids it. A rewrite in next.config.ts sends a request for one path to another address, while the visitor's address stays the same. Its destination can be another server. Allow only the paths that are public:
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, async rewrites() { return [ { // Only the public reviews of a tea, for ids like "mint". No other path on the service. source: "/reviews-api/reviews/:tea([a-z-]{1,30})", destination: `${process.env.REVIEWS_API_URL}/reviews/:tea`, }, ]; },}; export default nextConfig;GET /reviews-api/reviews/mint200
- Sent with the request
hostteashop.examplecookiesession=asha-sessionauthorizationToken asha-token
- Response headers
content-typeapplication/jsonVaryAccept-Encoding
The body that arrived, as it arrived
{"tea":"mint","reviews":[{"author":"Asha","email":"asha@example.com","stars":5,"text":"Fresh and bright."},{"author":"Ben","email":"ben@example.com","stars":4,"text":"Calm and grassy."}]}Lines 7 to 15 of next.config.ts add the rewrite. Line 11 matches /reviews-api/reviews/ followed by a tea id, 1 to 30 lowercase letters and dashes, named tea. Line 12 sends it to that tea's reviews on the reviews service, and nowhere else. A pattern like /reviews-api/:path* would have sent every path, the service's private ones too: here, /reviews-api/staff/notes and /reviews-api/reviews/..%2Fstaff%2Fnotes matched no rewrite and answered 404. The recorder asked for /reviews-api/reviews/mint the way a signed-in visitor's browser would, on the address teashop.example, with a session cookie and an Authorization header. The Network tab shows the answer: the reviews service's JSON, exactly as it sent it, both emails included. And the service received this:
| Request the service received | Its status | The headers it came with |
|---|---|---|
GET /reviews/mint | 200 | host: 127.0.0.1:<port>, x-forwarded-host: teashop.example, cookie: session=asha-session, authorization: Token asha-token |
host is the service's own address, and x-forwarded-host is the one the visitor used: teashop.example. In Next.js 16.4.3, a rewrite to another server is passed on by a proxy that sets both (lines 30 to 41 of proxy-request.ts). The visitor's cookie and authorization headers went along too, untouched: a rewrite in next.config.ts has no way to remove them. And a service should only trust x-forwarded-host when a server you run in front of it, like your load balancer, overwrites it: anyone can send that header, so it never decides who may do what (Self-hosting and Multi-tenant apps show where it comes from). Also, rewrites() runs during next build, and next start reads the list the build saved (lines 540 to 560 of filesystem.ts), so changing REVIEWS_API_URL means building again.
Only rewrite to a service you'd trust with your visitors' cookies and sign-in headers, because it gets them all, and its answer reaches the browser as it is. Allow only public paths, never :path*. For any other service, use proxy.ts to send a copy of the request without those headers, or a Route Handler that builds its own request and returns only what the page needs. The playground below records both.
Here are four ways to reach the reviews service: the rewrite above; a rewrite in proxy.ts (proxy.ts) that sends a copy of the visitor's headers without the cookie and the Authorization header; a Route Handler that builds the service's address from the query; and one that checks the id first. The recorder sent each one Mint tea from the signed-in visitor, then a path to the staff notes. Guess what the second request does to the unchecked Route Handler.
next.config.tsimport type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ cacheComponents: true, partialPrefetching: true, async rewrites() { return [ { // Only the public reviews of a tea, for ids like "mint". No other path on the service. source: "/reviews-api/reviews/:tea([a-z-]{1,30})", destination: `${process.env.REVIEWS_API_URL}/reviews/:tea`, }, ]; },}; export default nextConfig;GET /reviews-api/reviews/mint200
- Sent with the request
hostteashop.examplecookiesession=asha-sessionauthorizationToken asha-token
- Response headers
content-typeapplication/jsonVaryAccept-Encoding
The body that arrived, as it arrived
{"tea":"mint","reviews":[{"author":"Asha","email":"asha@example.com","stars":5,"text":"Fresh and bright."},{"author":"Ben","email":"ben@example.com","stars":4,"text":"Calm and grassy."}]}Only /reviews-api/reviews/<id> is sent on, and the answer comes back as it is: both reviews, with each reviewer's email. /reviews-api/staff/notes answered 404.
Each row is one request the stand-in reviews service received, in each version, with the headers it kept:
| Version | Request the service received | The headers it came with |
|---|---|---|
| A rewrite in next.config.ts | GET /reviews/mint | host: 127.0.0.1:<port>, x-forwarded-host: teashop.example, cookie: session=asha-session, authorization: Token asha-token |
| A rewrite in proxy.ts | GET /reviews/mint | host: 127.0.0.1:<port>, x-forwarded-host: teashop.example |
| A Route Handler, unchecked | GET /reviews/mint | host: 127.0.0.1:<port> |
| A Route Handler, unchecked | GET /staff/notes | host: 127.0.0.1:<port> |
| A Route Handler, checked | GET /reviews/mint | host: 127.0.0.1:<port> |
The unchecked handler, on line 5, put the query straight into the address: /reviews/../staff/notes. A URL's .. means "the folder above", and it's resolved before the request leaves (URL objects), so the reviews service received GET /staff/notes, and the handler passed its staff notes to whoever asked. Building a request's address from what a caller sent is how SSRF, server-side request forgery, happens: your server, inside your network, asks for what an attacker chose. Letting the caller pick the whole address, like ?url=, is the worst case. The checked handler builds the address from fixed parts: line 8 lets only a valid id through, and getReviews also escapes it with encodeURIComponent. It sent no cookie and no Authorization header, because its fetch doesn't add them. The proxy.ts version sent neither too: lines 9 to 11 copy the headers and delete both, and line 12 passes the copy to NextResponse.rewrite as the request's headers.
next.config.ts, the reviews service got the visitor's cookie and Authorization header. Through the Route Handler, it got only the request the code made.Each row compares the two ways to put a service behind one of the shop's addresses:
| Question | A rewrite | A Route Handler |
|---|---|---|
| What reaches the service | In next.config.ts: every request header, cookies and Authorization included, with no way to remove them. In proxy.ts: a copy of the headers your code chose. | Only what your code puts in its fetch. |
| Which paths reach it | Only the ones the rule allows. A pattern for public paths, never :path*, which would reach the service's private ones too. | Only the address your code builds, from fixed parts and a checked id. |
| What reaches the browser | The service's answer, as it is. | Whatever your code returns: often a smaller object. |
| Where it's written | rewrites() in next.config.ts, which runs during next build (next start uses the list the build saved), or proxy.ts, which runs for each matching request. | A route.ts file, with your own checks. |
| Good for | A public path on a service: in next.config.ts when you'd trust it with your visitors' cookies, in proxy.ts when you remove them first. | Everything else: services that need a key, answers that need shaping, input that needs checking. |
Combining several services
The page and the phone app's endpoint both get their data from one function, which calls both services at once:
import "server-only";import { NoSuchTea } from "@/services/errors";import { getReviews } from "@/services/reviews";import { getStock } from "@/services/stock"; export async function getTeaSummary(id: string) { const [stock, reviews] = await Promise.allSettled([getStock(id), getReviews(id)]); // A 404 says there's no such tea: an answer to pass on, not a failure to hide. if ([stock, reviews].some((result) => result.status === "rejected" && result.reason instanceof NoSuchTea)) return null; return { stock: stock.status === "fulfilled" ? stock.value : null, reviews: reviews.status === "fulfilled" ? reviews.value : null, };} Line 7 starts both calls before it waits for either, so they run at the same time (Parallel and sequential fetching showed why that matters). It waits with Promise.allSettled, which waits until every promise has finished, and says for each one whether it was fulfilled or rejected (Promise combinators). Line 9 tells two kinds of failure apart: a NoSuchTea error, from a service that answered 404, means the tea doesn't exist, so the summary is null and the page and the API answer "not found". Anything else, like a timeout or a 503, is a passing problem: lines 11 and 12 turn it into null for that service alone, so one broken service never breaks the other's part of the page. Each service's module also decides how long to wait, and checks what it got back:
import "server-only";import { z } from "zod";import { NoSuchTea } from "./errors"; const Stock = z.object({ boxes: z.number().int().min(0) }); export async function getStock(id: string) { const response = await fetch(`${process.env.STOCK_API_URL}/stock/${encodeURIComponent(id)}`, { headers: { "x-api-key": process.env.STOCK_API_KEY ?? "" }, signal: AbortSignal.timeout(1000), }); if (response.status === 404) throw new NoSuchTea(id); if (!response.ok) throw new Error(`The stock service answered ${response.status}`); const stock = Stock.parse(await response.json()); return { boxes: stock.boxes };} - Line 5 describes the answer the shop expects, with Zod: a whole number of boxes, 0 or more.
- Line 8 builds the address from the service's address and the id, escaped with
encodeURIComponent. Line 9 sends the shop's key, from a server environment variable. - Line 10 gives the request a signal: an object that can stop it.
AbortSignal.timeout(1000)makes one that stops the request after 1000 ms, andfetchthen rejects (fetch). - Line 12 turns
404intoNoSuchTea, fromservices/errors.ts. Line 13 turns any other failed answer, like503, into an error, becausefetchonly rejects when there's no answer at all. - Line 14 checks the JSON against the schema, and throws if the service sent something else, so a changed or broken answer is a failure, not a page full of
undefined.
services/reviews.ts does the same, with its own 1-second timeout. Now make the services misbehave. Each button below is the same app, recorded with the stand-in services answering differently. Before you click, guess what the page shows when the reviews take 5 seconds.
app/teas/[id]/page.tsximport { notFound } from "next/navigation";import { Suspense } from "react";import { getTeaSummary } from "@/lib/tea-summary"; export default function TeaPage({ params }: PageProps<"/teas/[id]">) { return ( <main> <h1>Tea details</h1> <Suspense fallback={<p>Loading…</p>}> <Details params={params} /> </Suspense> </main> );} async function Details({ params }: { params: Promise<{ id: string }> }) { const { id } = await params; const summary = await getTeaSummary(id); if (!summary) notFound(); const { stock, reviews } = summary; return ( <> <p>{stock ? `${stock.boxes} boxes in stock` : "Stock unknown right now."}</p> {!reviews ? ( <p>Reviews are unavailable right now.</p> ) : reviews.latest ? ( <p> Rated {reviews.average} from {reviews.count} reviews. {reviews.latest.author}: {reviews.latest.text} </p> ) : ( <p>No reviews yet.</p> )} </> );}12 boxes in stock
Rated 4.5 from 2 reviews. Asha: Fresh and bright.
The page as the browser shows it when its HTML arrives, drawn with plain styles. Its JavaScript doesn't run here.
Both services answer at once. Line 18 gets both results, and the page shows 12 boxes in stock and Rated 4.5 from 2 reviews.
With the reviews service waiting 5 seconds, the reviews call was aborted by its timeout, and the page showed 12 boxes in stock and Reviews are unavailable right now. It waited for the slow service only as long as its timeout. With the stock service answering 503, the page showed Stock unknown right now. and the reviews. Either way, the visitor got everything that worked.
Promise.allSettled starts both calls at once. The reviews call's AbortSignal.timeout(1000) stops it after a second, long before the stand-in's 5-second answer, so the page shows the stock and the reviews' fallback. Times aren't to scale.This page has one Suspense boundary around both parts, so the stock waits too: both show once the reviews call has answered or timed out. To show the stock as soon as it arrives, give each part its own component and its own Suspense, so the reviews stream in later (Streaming); a timeout still decides how long the slowest part may keep the page open. And the phone app's endpoint combines the services the same way, because it calls the same getTeaSummary: the recorded JSON from /api/v1/teas/mint has both parts.
Shaping data for the UI
Here is what the reviews service sends for Mint tea:
{
"tea": "mint",
"reviews": [
{
"author": "Asha",
"email": "asha@example.com",
"stars": 5,
"text": "Fresh and bright."
},
{
"author": "Ben",
"email": "ben@example.com",
"stars": 4,
"text": "Calm and grassy."
}
]
}Every review, each with its reviewer's email. The page shows an average, a count and the latest review. Turning a service's answer into exactly what one screen needs is a backend for frontend's main job, and it happens in the service's module:
import "server-only";import { z } from "zod";import { NoSuchTea } from "./errors"; const Reviews = z.object({ reviews: z.array(z.object({ author: z.string(), stars: z.number().min(1).max(5), text: z.string() })),}); export async function getReviews(id: string) { const response = await fetch(`${process.env.REVIEWS_API_URL}/reviews/${encodeURIComponent(id)}`, { signal: AbortSignal.timeout(1000), }); if (response.status === 404) throw new NoSuchTea(id); if (!response.ok) throw new Error(`The reviews service answered ${response.status}`); const { reviews } = Reviews.parse(await response.json()); const stars = reviews.reduce((total, review) => total + review.stars, 0); return { average: reviews.length ? stars / reviews.length : null, count: reviews.length, latest: reviews[0] ? { author: reviews[0].author, text: reviews[0].text } : null, };} Lines 5 to 7 describe the fields the shop uses: each review's author, stars from 1 to 5, and text. Line 15 checks the answer against them. Zod's parse also drops every field the schema doesn't name, so the emails are gone from here on. Line 16 adds up the stars, and lines 17 to 21 return a new object: the average on line 18 ((5 + 4) / 2, 4.5), the count on line 19, and on line 20 the latest review with only its author and text. A tea with no reviews gets null for both, instead of a division by zero, and the page shows No reviews yet., as the recorded /teas/green does. An object made only to carry data out of a layer like this is a DTO, a data transfer object, as in Authorization and the data access layer. Anything a Server Component renders, or passes to a Client Component, is sent to the browser, so leaving fields out here is how they stay out of the page.
The recordings show the difference. The recorder searched the tea page's HTML and its JavaScript: no asha@example.com in either. The phone app's JSON has no email either. Only the rewrite, which passes the service's answer on as it is, sent the emails to the browser.
Keeping secrets on the server
The stock service's key lives in an environment variable without the NEXT_PUBLIC_ prefix, so only server code can read it (Server-only code and environment variables). In these recordings, it's in the project's .env file:
# A key made up for the recordings. A real one lives in your host's settings.
STOCK_API_KEY=stock-key-for-recordings
Line 2 sets STOCK_API_KEY. The value is made up for the recordings; a real key lives in your host's settings, never in the repository. Line 9 of services/stock.ts sends it. That module starts with import "server-only", so no Client Component can import it. The stand-in stock service answers 401 to a request without the right key, and it answered 200: the key arrived. The recorder also searched the tea page's HTML and its JavaScript for the key, and found it in neither.
Writing works the same way. A Server Action can stand in front of a service too: here, a form posts a review, and the action sends it to the reviews service with the shop's second key.
app/teas/[id]/actions.ts"use server"; import { cookies } from "next/headers";import { redirect } from "next/navigation";import { teaId } from "@/lib/public-api";import { postReview } from "@/services/reviews"; export async function addReview(id: string, formData: FormData) { // A stand-in for the signed session cookie from the authentication lesson. const session = (await cookies()).get("session")?.value; if (session !== "asha-session") throw new Error("Sign in to write a review."); const text = String(formData.get("text") ?? "").trim().slice(0, 500); if (!teaId.test(id) || !text) throw new Error("Write a review of a tea."); await postReview(id, { author: "Asha", text }); redirect(`/teas/${id}`);}POST /teas/mint303
- Sent with the request
cookiesession=asha-session
- Typed into the form (sent with its hidden fields)
textLovely with honey.
- Response headers
cache-controlno-cache, no-store, max-age=0, must-revalidatelocation/teas/mintvaryrsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetchx-nextjs-cacheHITx-nextjs-prerender1
No HTML arrived: a redirect sends none
In app/teas/[id]/actions.ts, line 10 reads the session cookie and line 11 refuses anyone who isn't signed in. The cookie check is a stand-in for the signed session of Authentication; an action is a public endpoint, so it always checks who is asking. Line 13 checks the tea's id, which travels back in the form as a bound argument, and the text. Line 14 calls postReview, which sends it with REVIEWS_API_KEY. The Network tab shows the form posted without JavaScript: 303, back to /teas/mint. The reviews service received this:
| Request the service received | Its status | The headers it came with |
|---|---|---|
GET /stock/mint | 200 | host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings |
GET /reviews/mint | 200 | host: 127.0.0.1:<port> |
GET /stock/mint | 200 | host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings |
GET /reviews/mint | 200 | host: 127.0.0.1:<port> |
POST /reviews/mint/new | 200 | host: 127.0.0.1:<port>, x-api-key: reviews-key-for-recordings |
The last row is the review, with the shop's key, and its body was {"author":"Asha","text":"Lovely with honey."}. The browser posted to the shop's own address; the shop talked to the service. Neither key is in the page's JavaScript.
When to use a separate backend
A backend for frontend is good at short requests: read from a service or a database, shape the answer, send it. Next.js's guide says plainly that its server features aren't a full backend replacement, and lists what some hosts do to Route Handlers: they may run them as short-lived functions that can't share data between requests, may stop long-running ones on a timeout, and close the connection after the answer, so WebSockets won't work. Each row is one kind of work, and where it fits best:
| The work | In the Next.js app | In a separate backend |
|---|---|---|
| Short requests that read or write through services or a database | A good fit: a page, a Route Handler or a Server Action does the call and answers. | Not needed. |
| Jobs that run for minutes, like a stock import | Next.js's docs warn that some hosts stop long-running Route Handlers on a timeout. | A job queue and a worker that runs as long as it needs. |
| Live connections, like a chat between staff | The docs say WebSockets won't work where Route Handlers run as functions that end after their answer. | A service built for long-lived connections. |
| Rules that a website, a phone app and partners all share | Each frontend can shape data for itself. | The rules themselves, in one service every client calls, so they never differ. |
| Teams that release on their own schedules | One app means one deploy for every change. | A service each team owns and deploys. |
Many apps end up with both: services that own the data and the rules, and a Next.js app in front of them that owns the pages and the shape of the data for those pages. Small jobs that can happen after the answer, like logging, fit in the app with after() (after() and background work). Jobs that take minutes, live connections, and rules that several clients share are worth a backend of their own.
Planning the tea shop's backend
The shop has eight jobs to place. Sort them: does each one belong in the Next.js app, or in a separate backend?
- Turn the reviews service's answer into an average, a count and the latest review
- Call the stock service with its key
- Ask two services at once for one page, with a timeout for each
- Answer the phone app's request for a tea, with a client id and a rate limit
- Import 200,000 stock rows from a supplier every night
- A live chat between the shop's staff
- The price rules the website, the phone app and partners all use
- Send the newsletter to 50,000 customers
Sort each job: is it a short request the Next.js app can answer, or work that needs a backend of its own?
A quick test: if the job ends with an answer to one request within a few seconds, the Next.js app can do it. If it runs for minutes, keeps a connection open, or holds rules other clients need too, give it a backend.
Common mix-ups
- "A rewrite only changes the path." No. The service got the visitor's cookie and
Authorizationheader, and its answer reached the browser unchanged, emails included. - "CORS keeps other programs out of my API." No. CORS only decides which pages a browser lets read the answer. The phone app isn't a browser; the key check and the rate limit are what refuse it.
- "
Promise.allis fine for independent services." No. It rejects as soon as one call fails. With the stock service down, the recorded HTML held onlyLoading…. - "A client id proves which app is calling." No. It ships inside the app, so anyone can copy it and send it. It's only a label for counting; people prove who they are by signing in.
- "Putting the id in the path keeps the request inside
/reviews." No.../staff/notesreached/staff/notes. Check ids and escape them. - "A Server Component should fetch the API like the phone app." No. It calls
getTeaSummarydirectly; the Route Handler is for callers that can't. - "Next.js can replace every backend." No. Its own guide says it isn't a full replacement: long jobs, live connections and shared rules belong elsewhere.
Practice exercises
These exercises are about Next.js projects, which can't run inside this page. Answer first, then use the hints.
The stock service answers 503. What does this page say about the stock?
import { notFound } from "next/navigation";
import { Suspense } from "react";
import { getTeaSummary } from "@/lib/tea-summary";
export default function TeaPage({ params }: PageProps<"/teas/[id]">) {
return (
<main>
<h1>Tea details</h1>
<Suspense fallback={<p>Loading…</p>}>
<Details params={params} />
</Suspense>
</main>
);
}
async function Details({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const summary = await getTeaSummary(id);
if (!summary) notFound();
const { stock, reviews } = summary;
return (
<>
<p>{stock ? `${stock.boxes} boxes in stock` : "Stock unknown right now."}</p>
{!reviews ? (
<p>Reviews are unavailable right now.</p>
) : reviews.latest ? (
<p>
Rated {reviews.average} from {reviews.count} reviews. {reviews.latest.author}: {reviews.latest.text}
</p>
) : (
<p>No reviews yet.</p>
)}
</>
);
}
Stock unknown right now. The stock service answered 503, getStock threw, and Promise.allSettled made stock null. The recording shows that sentence, then the reviews.
A signed-in visitor asks for /reviews-api/reviews/mint. Which two of the visitor's headers reach the reviews service, although it doesn't need them?
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
cacheComponents: true,
partialPrefetching: true,
async rewrites() {
return [
{
// Only the public reviews of a tea, for ids like "mint". No other path on the service.
source: "/reviews-api/reviews/:tea([a-z-]{1,30})",
destination: `${process.env.REVIEWS_API_URL}/reviews/:tea`,
},
];
},
};
export default nextConfig;
The cookie and the Authorization header. The recorded request to the reviews service had cookie: session=asha-session and authorization: Token asha-token. A Route Handler's fetch sends neither unless your code adds them.
The phone app has called four times in this minute. Which status does its fifth call get?
import "server-only";
// The apps that call this API. A client id says which app it is. It ships inside the app,
// so anyone can copy it: it's for counting and coarse limits, never for deciding who sees what.
const clients = new Set(["phone-app"]);
// A stand-in rate limit: 4 requests a minute for each client id, counted in this server's memory.
const calls = new Map<string, { start: number; count: number }>();
const minute = 60_000;
// Recording fixture: FIXED_NOW freezes the clock, so the recording never depends on timing. Never set it in production.
const now = () => (process.env.FIXED_NOW ? Number(process.env.FIXED_NOW) : Date.now());
export function limitClient(request: Request) {
const client = request.headers.get("x-client-id");
if (!client || !clients.has(client)) return Response.json({ error: "Send a known x-client-id" }, { status: 400 });
const time = now();
const current = calls.get(client);
if (!current || time - current.start >= minute) {
calls.set(client, { start: time, count: 1 });
return null;
}
current.count += 1;
if (current.count <= 4) return null;
const retryAfter = Math.ceil((current.start + minute - time) / 1000);
return Response.json({ error: "Too many requests" }, { status: 429, headers: { "retry-after": String(retryAfter) } });
}
export const teaId = /^[a-z-]{1,30}$/;
429. The first call starts a minute with a count of 1; the next three raise it to 4; the fifth raises it to 5, so line 24 doesn't let it through and line 26 answers 429, with the seconds left in the minute in retry-after. The recorded fifth call got exactly that, with retry-after: 60 from the frozen clock.
Replace the comment on line 11 with the line that makes the reviews call give up after one second.
import "server-only";
import { z } from "zod";
import { NoSuchTea } from "./errors";
const Reviews = z.object({
reviews: z.array(z.object({ author: z.string(), stars: z.number().min(1).max(5), text: z.string() })),
});
export async function getReviews(id: string) {
const response = await fetch(`${process.env.REVIEWS_API_URL}/reviews/${encodeURIComponent(id)}`, {
// Give up after one second.
});
if (response.status === 404) throw new NoSuchTea(id);
if (!response.ok) throw new Error(`The reviews service answered ${response.status}`);
const { reviews } = Reviews.parse(await response.json());
const stars = reviews.reduce((total, review) => total + review.stars, 0);
return {
average: reviews.length ? stars / reviews.length : null,
count: reviews.length,
latest: reviews[0] ? { author: reviews[0].author, text: reviews[0].text } : null,
};
}
import "server-only";
import { z } from "zod";
import { NoSuchTea } from "./errors";
const Reviews = z.object({
reviews: z.array(z.object({ author: z.string(), stars: z.number().min(1).max(5), text: z.string() })),
});
export async function getReviews(id: string) {
const response = await fetch(`${process.env.REVIEWS_API_URL}/reviews/${encodeURIComponent(id)}`, {
signal: AbortSignal.timeout(1000),
});
if (response.status === 404) throw new NoSuchTea(id);
if (!response.ok) throw new Error(`The reviews service answered ${response.status}`);
const { reviews } = Reviews.parse(await response.json());
const stars = reviews.reduce((total, review) => total + review.stars, 0);
return {
average: reviews.length ? stars / reviews.length : null,
count: reviews.length,
latest: reviews[0] ? { author: reviews[0].author, text: reviews[0].text } : null,
};
}
signal: AbortSignal.timeout(1000),. After 1000 ms the signal aborts the request, fetch rejects, and Promise.allSettled turns the reviews into null. The recording with the 5-second reviews service shows the fallback.
Someone calls this handler with ?tea=../staff/notes. Which path does the reviews service receive?
import type { NextRequest } from "next/server";
export async function GET(request: NextRequest) {
const tea = request.nextUrl.searchParams.get("tea");
const response = await fetch(`${process.env.REVIEWS_API_URL}/reviews/${tea}`);
return Response.json(await response.json(), { status: response.status });
}
import type { NextRequest } from "next/server";
import { teaId } from "@/lib/public-api";
import { NoSuchTea } from "@/services/errors";
import { getReviews } from "@/services/reviews";
export async function GET(request: NextRequest) {
const tea = request.nextUrl.searchParams.get("tea") ?? "";
if (!teaId.test(tea)) return Response.json({ error: "Not a tea id" }, { status: 400 });
try {
return Response.json(await getReviews(tea));
} catch (error) {
if (error instanceof NoSuchTea) return Response.json({ error: "No such tea" }, { status: 404 });
return Response.json({ error: "Reviews are unavailable" }, { status: 503 });
}
}
/staff/notes. Line 5 built /reviews/../staff/notes, and .. stepped out of /reviews. The stand-in recorded GET /staff/notes. The fixed handler checks the id first and answers 400.
This version of lib/tea-summary.ts uses Promise.all, and the stock service answers 503. Which of the stock and the reviews reach the tea page's HTML?
import "server-only";
import { getReviews } from "@/services/reviews";
import { getStock } from "@/services/stock";
export async function getTeaSummary(id: string) {
const [stock, reviews] = await Promise.all([getStock(id), getReviews(id)]);
return { stock, reviews };
}
import "server-only";
import { NoSuchTea } from "@/services/errors";
import { getReviews } from "@/services/reviews";
import { getStock } from "@/services/stock";
export async function getTeaSummary(id: string) {
const [stock, reviews] = await Promise.allSettled([getStock(id), getReviews(id)]);
// A 404 says there's no such tea: an answer to pass on, not a failure to hide.
if ([stock, reviews].some((result) => result.status === "rejected" && result.reason instanceof NoSuchTea)) return null;
return {
stock: stock.status === "fulfilled" ? stock.value : null,
reviews: reviews.status === "fulfilled" ? reviews.value : null,
};
}
Neither: only the Loading… fallback. Promise.all rejected as soon as the stock call failed, so Details threw and the HTML never got the details; next start logged Error: The stock service answered 503. With Promise.allSettled, the reviews still show.
Check your understanding
For each question, ask what each server received, and what reached the browser.
Question 1 of 7Why does the tea page call the stock service from the server, not from the browser?
Choose an answer to see the explanation.
Question 2 of 7A signed-in visitor asked for
/reviews-api/reviews/mint, which a rewrite sends to the reviews service. What did the service receive?Choose an answer to see the explanation.
Question 3 of 7The reviews service waited 5 seconds before answering, and
getReviewsgives up after 1 second. What did the tea page show?Choose an answer to see the explanation.
Question 4 of 7Why does
getTeaSummaryusePromise.allSettledinstead ofPromise.all?Choose an answer to see the explanation.
Question 5 of 7This Route Handler was asked for
/api/reviews?tea=../staff/notes. What happened?Read the code, then predictTypeScriptimport type { NextRequest } from "next/server"; export async function GET(request: NextRequest) { const tea = request.nextUrl.searchParams.get("tea"); const response = await fetch(`${process.env.REVIEWS_API_URL}/reviews/${tea}`); return Response.json(await response.json(), { status: response.status }); }Choose an answer to see the explanation.
Question 6 of 7The phone app called
/api/v1/teas/mintfour times this minute. What does its fifth call get?Choose an answer to see the explanation.
Question 7 of 7Which job is better in a separate backend than in the Next.js app?
Choose an answer to see the explanation.
Key takeaways
- A backend for frontend stands between one frontend and the services behind it. In Next.js, Server Components, Route Handlers and Server Actions call the services on the server.
- A public Route Handler is an API: version it, check what it's sent, answer 404 for what doesn't exist, and rate-limit it. A client id only labels the app; people sign in. CORS is only about browsers.
- A rewrite in
next.config.tspasses the visitor's request on, cookies andAuthorizationincluded, and returns the answer unchanged. Allow only public paths, and useproxy.tsor a Route Handler when a service mustn't get those headers. - Never build a service's address from what a caller sent: check ids, escape them, and keep the rest of the address fixed.
- Call services in parallel with
Promise.allSettledand a timeout for each, so a slow or broken service only costs its own part of the page. - Shape each answer into a DTO and keep keys in server-only modules. Long jobs, live connections and rules shared by many clients belong in a separate backend.
Remember the one-liner.
The browser asks the shop; the shop asks the services, in parallel, with timeouts, and sends back only what the page needs.
Coming next: Monorepos. Several apps and shared packages in one repository, like a design system that the shop's website and its staff dashboard both use.