cf.completefrontend
Open lab My progress
THE NEXT.JS FIELD GUIDE

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.

By the end, you can
  • 01
    Put 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.
  • 02
    Open 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.
  • 03
    Decide 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:

The tea page asks both services on the serverNext.js
app/teas/[id]/lib/services/
app/teas/[id]/page.tsx
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>      )}    </>  );}
Recorded from Next.js 16.4.3
localhost:3000/teas/mintTea shop
Tea details

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:

What the stand-in services received for three visits, as recorded
Request the service receivedIts statusThe headers it came with
GET /stock/mint200host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings
GET /reviews/mint200host: 127.0.0.1:<port>
GET /stock/green200host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings
GET /reviews/green200host: 127.0.0.1:<port>
GET /stock/oolong404host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings
GET /reviews/oolong404host: 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 browser window asks the Next.js app for /teas/mint, one page. The Next.js app sends two requests on the server: GET /stock/mint, with the x-api-key header, to the stock service, and GET /reviews/mint to the reviews service.browser/teas/mintasks for one pageNext.jsTeaPagegetTeaSummarystock/stock/mintx-api-keyreviews/reviews/mint
The browser only talks to the shop. The shop's server calls the stock service, with its key, and the reviews service, at the same time, and sends one page back.
Real-life analogyA waiter between the tables and the kitchen

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
The main idea

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.

lib/public-api.ts: which app is calling, how often, and what an id looks likeTypeScript
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-id header, and line 16 answers 400 when 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 in retry-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_NOW set, the clock stands still: that's a recording fixture, set in this example's .env so 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.
The phone app's endpoints, version 1 and version 2Next.js
app/api/v1/teas/[id]/v2/teas/[id]/teas/[id]/lib/services/
app/api/v1/teas/[id]/route.ts
import { 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);}
Recorded from Next.js 16.4.3

GET /api/v1/teas/mint200

Sent with the request
x-client-id phone-app
Response headers
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
content-type application/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, with retry-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:

The phone app's API, concern by concern
ConcernWhat the shop doesWhat the recording showed
VersionsTwo 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 sentteaId 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 callsEach 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 limitingAt 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' pagesNo 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:

A rewrite sends one public path to the reviews serviceNext.js
app/teas/[id]/lib/services/
next.config.ts
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;
Recorded from Next.js 16.4.3

GET /reviews-api/reviews/mint200

Sent with the request
host teashop.example
cookie session=asha-session
authorization Token asha-token
Response headers
content-type application/json
Vary Accept-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:

What the reviews service received through the rewrite, as recorded
Request the service receivedIts statusThe headers it came with
GET /reviews/mint200host: 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.

A rewrite passes everything on

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.

Playground: what gets through to the reviews service?
The tea shop, with this way to reach the reviews serviceNext.js
app/teas/[id]/lib/services/
next.config.ts
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;
Recorded from Next.js 16.4.3

GET /reviews-api/reviews/mint200

Sent with the request
host teashop.example
cookie session=asha-session
authorization Token asha-token
Response headers
content-type application/json
Vary Accept-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."}]}
Try it yourself
A rewrite in next.config.ts

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.

Every version was built with next build from Next.js 16.4.3 and run with next start. The Network tab shows what the shop answered; the table under the playground lists what the stand-in reviews service received. Reset goes back to the rewrite.

Each row is one request the stand-in reviews service received, in each version, with the headers it kept:

What the stand-in reviews service received, as recorded
VersionRequest the service receivedThe headers it came with
A rewrite in next.config.tsGET /reviews/minthost: 127.0.0.1:<port>, x-forwarded-host: teashop.example, cookie: session=asha-session, authorization: Token asha-token
A rewrite in proxy.tsGET /reviews/minthost: 127.0.0.1:<port>, x-forwarded-host: teashop.example
A Route Handler, uncheckedGET /reviews/minthost: 127.0.0.1:<port>
A Route Handler, uncheckedGET /staff/noteshost: 127.0.0.1:<port>
A Route Handler, checkedGET /reviews/minthost: 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.

Two halves. Left, a rewrite in next.config.ts: the cookie, the authorization header and x-forwarded-host all reach the reviews service, with the request for /reviews/mint. Right, a Route Handler: only GET /reviews/mint reaches the service; the cookie and the authorization header are crossed out, left behind.A rewrite in next.config.tsGET /reviews/mintcookieauthorizationx-forwarded-hostall of it reaches the serviceA Route HandlerGET /reviews/mintcookieauthorizationonly what the code sends
Through the rewrite in 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:

A rewrite or a Route Handler?
QuestionA rewriteA Route Handler
What reaches the serviceIn 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 itOnly 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 browserThe service's answer, as it is.Whatever your code returns: often a smaller object.
Where it's writtenrewrites() 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 forA 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:

lib/tea-summary.ts: both services, and what to do when one failsTypeScript
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:

services/stock.ts: the only file that calls the stock serviceTypeScript
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, and fetch then rejects (fetch).
  • Line 12 turns 404 into NoSuchTea, from services/errors.ts. Line 13 turns any other failed answer, like 503, into an error, because fetch only 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.

Playground: what does the page show when a service misbehaves?
The tea page, with the services answering like thisNext.js
app/teas/[id]/lib/services/
app/teas/[id]/page.tsx
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>      )}    </>  );}
Recorded from Next.js 16.4.3
localhost:3000/teas/mintTea shop
Tea details

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.

Try it yourself
Both answer

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.

Every version was built with next build from Next.js 16.4.3 and run with next start, with the stand-in services answering as the button says. Only the stand-in's answers change; the app's files are the same. Reset goes back to the first version.

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.

A timeline for one visit, with marks at the start, at 1 second and at 5 seconds. The stock call starts at once and answers. The reviews call starts at the same time and is cut off at 1 second by its timeout; the service's answer at 5 seconds, crossed out, never arrives. The page shows 12 boxes in stock and Reviews are unavailable right now.start1 s5 sstockanswers: 12reviewswaiting…answer at 5 stimeout: aborted12 boxes in stockReviews are unavailable right now.the page's details, sent with both answers settled
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:

What the reviews service sends for Mint teaJSON
{
  "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:

services/reviews.ts: the answer, checked and shaped for the shopTypeScript
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:

.env: the stock service's keyText
# 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.

A Server Action posts a review to the reviews serviceNext.js
app/teas/[id]/lib/services/
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}`);}
Recorded from Next.js 16.4.3

POST /teas/mint303

Sent with the request
cookie session=asha-session
Typed into the form (sent with its hidden fields)
text Lovely with honey.
Response headers
cache-control no-cache, no-store, max-age=0, must-revalidate
location /teas/mint
vary rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
x-nextjs-cache HIT
x-nextjs-prerender 1

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:

What the stand-in services received for a visit and a posted review, as recorded
Request the service receivedIts statusThe headers it came with
GET /stock/mint200host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings
GET /reviews/mint200host: 127.0.0.1:<port>
GET /stock/mint200host: 127.0.0.1:<port>, x-api-key: stock-key-for-recordings
GET /reviews/mint200host: 127.0.0.1:<port>
POST /reviews/mint/new200host: 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 Next.js app or a separate backend?
The workIn the Next.js appIn a separate backend
Short requests that read or write through services or a databaseA 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 importNext.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 staffThe 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 shareEach 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 schedulesOne 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?

The Next.js app or 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
Try it yourself
0 of 8 correct

Sort each job: is it a short request the Next.js app can answer, or work that needs a backend of its own?

Choose a category for every card. You can change an answer at any time; Reset clears them all.

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 Authorization header, 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.all is fine for independent services." No. It rejects as soon as one call fails. With the stock service down, the recorded HTML held only Loading….
  • "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/notes reached /staff/notes. Check ids and escape them.
  • "A Server Component should fetch the API like the phone app." No. It calls getTeaSummary directly; 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.

Exercise 1 · Warm-upThe stock service is down

The stock service answers 503. What does this page say about the stock?

Starter codeTSX
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>
      )}
    </>
  );
}

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upWhat went through the rewrite

    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?

    Starter codeTypeScript
    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;
    

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeThe fifth call

      The phone app has called four times in this minute. Which status does its fifth call get?

      Starter codeTypeScript
      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}$/;
      

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeGive up after a second

        Replace the comment on line 11 with the line that makes the reviews call give up after one second.

        Starter codeTypeScript
        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,
          };
        }
        

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeWhere did the request go?

          Someone calls this handler with ?tea=../staff/notes. Which path does the reviews service receive?

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

          Answer, then press Check. Spacing and letter case don’t matter.

            Exercise 6 · ChallengePromise.all with a broken service

            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?

            Starter codeTypeScript
            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 };
            }
            

            Answer, then press Check. Spacing and letter case don’t matter.

              Check your understanding

              For each question, ask what each server received, and what reached the browser.

              The backend for frontend quiz · 7 questionsScore: first tries count
              1. 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.

              2. 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.

              3. Question 3 of 7The reviews service waited 5 seconds before answering, and getReviews gives up after 1 second. What did the tea page show?

                Choose an answer to see the explanation.

              4. Question 4 of 7Why does getTeaSummary use Promise.allSettled instead of Promise.all?

                Choose an answer to see the explanation.

              5. Question 5 of 7This Route Handler was asked for /api/reviews?tea=../staff/notes. What happened?

                Read the code, then predictTypeScript
                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 });
                }
                

                Choose an answer to see the explanation.

              6. Question 6 of 7The phone app called /api/v1/teas/mint four times this minute. What does its fifth call get?

                Choose an answer to see the explanation.

              7. 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.ts passes the visitor's request on, cookies and Authorization included, and returns the answer unchanged. Allow only public paths, and use proxy.ts or 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.allSettled and 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.

              CompleteFrontend Clear concepts. Working examples.