Type something to search...
Using Next.js as a backend for your frontend

Using Next.js as a backend for your frontend

Most people reach for Next.js because they want to render a UI — Server Components, layouts, streaming HTML, the whole App Router experience. But there's a second, quieter use case that doesn't get nearly enough attention: using Next.js purely as a backend. No pages, no layouts, no rendered markup at all — just an API surface that a separate frontend (a native mobile app, a different web app, a third-party integration, even another Next.js project) talks to over HTTP.

This is officially called the "Backend for Frontend" pattern, and Next.js supports it as a first-class use case rather than a workaround. If you've ever wondered whether it's reasonable to spin up a Next.js project with zero page.tsx files and use it exclusively as an API layer, the answer is yes — and this article walks through exactly how that works, what tools you have available, and where the sharp edges are.

What "Backend for Frontend" Actually Means Here

The term BFF usually describes an API layer built specifically to serve one frontend's needs — shaping, aggregating, and securing data before it reaches the client, rather than exposing your raw internal services directly. Next.js's take on this is refreshingly unopinionated: it gives you Route Handlers (public HTTP endpoints), Proxy (a gatekeeper that runs before those endpoints), and — if you're still on the Pages Router — API Routes, then gets out of your way.

It's worth being precise about what this is not. Next.js's backend capabilities are not a replacement for a full backend service. There's no built-in database layer, no job queue, no long-running background workers. What you get is an API layer that's publicly reachable, can handle any HTTP method, and can return any content type. Think of it as the glue and orchestration layer — the thing that talks to your actual data sources (a database, a CMS, a third-party API, a message queue) and shapes what comes back — rather than the data sources themselves.

This distinction matters because it tells you when to reach for this pattern. If you need a public endpoint that fetches from three internal services, merges the results, strips out fields your frontend doesn't need, and returns clean JSON, that's exactly what Route Handlers are for. If you need persistent WebSocket connections, cron-style background jobs, or in-memory state shared across requests, you're going to want a dedicated backend service instead — Next.js's serverless-first deployment model actively works against those use cases, which we'll get into later.

Scaffolding a Backend-Only Project

If you're starting fresh and know you want an API-first project, create-next-app has a flag for exactly this:

npx create-next-app@latest --api

This scaffolds a project with an example route.ts already in the app/ folder, showing the minimal shape of a Route Handler. It won't strip out the frontend tooling entirely — you still get the App Router, Tailwind config, and so on if you selected them — but it signals the intended starting point and saves you from copy-pasting a route handler from the docs on day one.

If you're retrofitting an existing full-stack Next.js app to also expose a clean API surface (a very common scenario — you already have a marketing site or dashboard in Next.js, and now a mobile team needs an endpoint), you don't need this flag at all. You just start adding route.ts files under app/api/ or wherever makes sense for your URL structure.

Public Endpoints with Route Handlers

A Route Handler is defined with a route.ts (or .js) file, and it exports a function named after the HTTP method it should handle:

// app/api/route.ts
export function GET(request: Request) {}

That's it — that function now handles GET /api. There's no router configuration, no manual method dispatch; the file convention and the exported function name do all the work. You export POST, PUT, DELETE, PATCH, or any other HTTP verb the same way, in the same file, and Next.js wires each one to the matching request method.

One thing worth internalizing early: these are genuinely public HTTP endpoints. Any client on the internet can hit them unless you add your own authentication and authorization logic. Next.js doesn't put an auth wall in front of Route Handlers by default, and it shouldn't — that decision depends entirely on what the endpoint does. A public RSS feed should stay public. An endpoint that writes to a database absolutely should not.

Error handling deserves real attention here too, since a Route Handler that throws an unhandled exception just returns a generic 500 with no useful body. Wrap anything that can fail in try/catch, and be deliberate about what you send back:

// app/api/route.ts
import { submit } from "@/lib/submit";

export async function POST(request: Request) {
  try {
    await submit(request);
    return new Response(null, { status: 204 });
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : "Unexpected error";
    return new Response(message, { status: 500 });
  }
}

Notice the reason instanceof Error check — this is a genuinely useful pattern because JavaScript lets you throw literally anything, not just Error objects. A library might throw a string, an object, or something else entirely, and if you blindly do reason.message without checking, you'll get a runtime error inside your error handler, which is about as embarrassing as bugs get. The one thing the docs don't spell out clearly enough: never forward reason.message straight to the client in a production app without thinking about it first. Internal error messages can leak stack traces, database column names, or internal service URLs. It's fine for early development, but before you ship, swap in a generic client-facing message and log the real one server-side.

Serving Content Types Beyond HTML

This is where Route Handlers earn their keep as a genuine backend tool rather than just "a place to put server logic." They aren't limited to JSON — you can return XML, plain text, binary data, or anything else, because you're constructing a Response object directly and controlling its headers.

The docs give an RSS feed as the canonical example, and it's a good one because it shows the full shape: fetch data, transform it into a different format, and set the right Content-Type:

// app/rss.xml/route.ts
export async function GET(request: Request) {
  const rssResponse = await fetch(/* rss endpoint */);
  const rssData = await rssResponse.json();

  const rssFeed = `<?xml version="1.0" encoding="UTF-8" ?>
<rss version="2.0">
<channel>
 <title>${rssData.title}</title>
 <description>${rssData.description}</description>
 <link>${rssData.link}</link>
</channel>
</rss>`;

  const headers = new Headers({ "content-type": "application/xml" });
  return new Response(rssFeed, { headers });
}

The file path app/rss.xml/route.ts is the interesting bit — the folder name becomes part of the URL, so this handler serves /rss.xml, not /rss.xml/route. Next.js already reserves a handful of these dynamic-file-as-route conventions for you: sitemap.xml, robots.txt, manifest.json, and the Open Graph/favicon image conventions. Anything outside that list — an llms.txt for AI crawlers, a .well-known endpoint for domain verification, a custom rss.xml — you build yourself with exactly this pattern.

One security note that's easy to skip past: if you're interpolating any user-controlled or external data into a string like the RSS XML above, sanitize it first. XML and HTML injection through a "harmless" feed endpoint is a real, exploitable vector, especially since RSS readers and aggregators will happily render whatever markup ends up in that feed.

Content Negotiation: One URL, Different Formats Depending on Who's Asking

This is genuinely one of the more clever patterns Next.js documents, and it's increasingly relevant now that AI agents and crawlers routinely prefer plain Markdown over rendered HTML. The idea: serve the same logical URL differently depending on the request's Accept header, using a rewrites config that matches on headers rather than just paths.

// next.config.js
module.exports = {
  async rewrites() {
    return [
      {
        source: "/docs/:slug*",
        destination: "/docs/md/:slug*",
        has: [
          {
            type: "header",
            key: "accept",
            value: "(.*)text/markdown(.*)",
          },
        ],
      },
    ];
  },
};

A request to /docs/getting-started with Accept: text/markdown gets silently rewritten to /docs/md/getting-started, where a Route Handler returns the raw Markdown source. Anyone else — a normal browser — hits the same URL and gets the regular rendered HTML page, completely unaware the rewrite even exists.

// app/docs/md/[...slug]/route.ts
import { getDocsMd, generateDocsStaticParams } from "@/lib/docs";

export async function generateStaticParams() {
  return generateDocsStaticParams();
}

export async function GET(_: Request, ctx: RouteContext<"/docs/md/[...slug]">) {
  const { slug } = await ctx.params;
  const mdDoc = await getDocsMd({ slug });

  if (mdDoc == null) {
    return new Response(null, { status: 404 });
  }

  return new Response(mdDoc, {
    headers: {
      "Content-Type": "text/markdown; charset=utf-8",
      Vary: "Accept",
    },
  });
}

The Vary: Accept header here is not decoration — skip it and you risk a CDN or shared cache serving a cached Markdown response to a regular browser (or the reverse), because most caches key purely on URL by default. Setting Vary explicitly tells every cache layer in the chain that the response body depends on that header, so it needs to cache both variants separately. Most modern CDNs already respect Accept in their default cache key, but "most" isn't "all," and this is the kind of bug that's invisible in development and only shows up in production once a cache actually gets populated with the wrong variant.

The other detail worth flagging: the rewrite doesn't lock down the destination route. /docs/md/getting-started is still directly reachable even if someone bypasses the Accept-header dance entirely. If you want that route to only ever be reached through the rewrite (say, because it skips some validation the rewrite normally guarantees), you need a Proxy check in front of it — the rewrite alone won't enforce that.

Consuming Request Payloads

Reading the body of an incoming request uses the standard Web Request instance methods — .json(), .formData(), .text() — the same API you'd use in a browser fetch call, just inverted to the server side. GET and HEAD requests don't carry bodies, so this only applies to POST, PUT, PATCH, and DELETE.

// app/api/send-email/route.ts
import { sendMail, validateInputs } from "@/lib/email-transporter";

export async function POST(request: Request) {
  const formData = await request.formData();
  const email = formData.get("email");
  const contents = formData.get("contents");

  try {
    await validateInputs({ email, contents });
    const info = await sendMail({ email, contents });
    return Response.json({ messageId: info.messageId });
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : "Unexpected exception";
    return new Response(message, { status: 500 });
  }
}

Validate before you trust — validateInputs here isn't a suggestion, it's the difference between a form endpoint and an open injection vector. One gotcha that catches people constantly: a request body can only be consumed once. If you try to call .json() twice on the same request, the second call throws, because the underlying stream has already been read and drained. If two different pieces of logic in your handler both need to inspect the body (say, one for logging and one for processing), clone the request first:

const clonedRequest = request.clone();
await request.text();
await clonedRequest.text(); // fine — independent stream
await request.text(); // throws — original stream already consumed

This is a subtle enough gotcha that it's worth just remembering as a rule: if more than one piece of code needs the body, clone before the first read, not after.

Manipulating and Aggregating Data

This is the core value proposition of the BFF pattern, and it's worth stating plainly: Route Handlers let you transform, filter, and combine data from one or more upstream sources before it ever reaches the client. That keeps business logic off the frontend (where it would need to be duplicated across every client — web, iOS, Android) and keeps your internal systems from being directly exposed to the public internet.

// app/api/weather/route.ts
import { parseWeatherData } from "@/lib/weather";

export async function POST(request: Request) {
  const body = await request.json();
  const searchParams = new URLSearchParams({ lat: body.lat, lng: body.lng });

  try {
    const weatherResponse = await fetch(`${weatherEndpoint}?${searchParams}`);
    if (!weatherResponse.ok) {
      /* handle error */
    }
    const weatherData = await weatherResponse.text();
    const payload = parseWeatherData.asJSON(weatherData);
    return new Response(payload, { status: 200 });
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : "Unexpected exception";
    return new Response(message, { status: 500 });
  }
}

Notice this example deliberately uses POST instead of GET, even though it's conceptually a read operation. The reasoning is that GET requests carrying geo-location data in the query string are more likely to get logged by proxies, browser history, or analytics tools than a POST body would be. It's a small design decision, but it's the kind of thing that separates a BFF layer designed with privacy in mind from one that just happens to work.

There's a broader win hiding in this pattern too: pushing computation server-side reduces client battery usage and mobile data consumption, since the aggregation, filtering, and parsing all happen once on your server instead of being duplicated in every client's JavaScript runtime.

Proxying to Another Backend

Sometimes the goal isn't to build new logic at all — it's to sit in front of an existing backend and add a layer of validation, authentication, or transformation before forwarding the request through.

// app/api/[...slug]/route.ts
import { isValidRequest } from "@/lib/utils";

export async function POST(request: Request, { params }) {
  const clonedRequest = request.clone();
  const isValid = await isValidRequest(clonedRequest);

  if (!isValid) {
    return new Response(null, { status: 400, statusText: "Bad Request" });
  }

  const { slug } = await params;
  const pathname = slug.join("/");
  const proxyURL = new URL(pathname, "https://nextjs.org");
  const proxyRequest = new Request(proxyURL, request);

  try {
    return fetch(proxyRequest);
  } catch (reason) {
    const message =
      reason instanceof Error ? reason.message : "Unexpected exception";
    return new Response(message, { status: 500 });
  }
}

The [...slug] catch-all segment is what makes this genuinely useful as a proxy rather than a single hardcoded forward — any path under /api/ gets captured into slug and reassembled against the real backend's URL. Note the request clone before validation: since isValidRequest presumably needs to read the body to validate it, and the body can only be consumed once, cloning first preserves the original for the actual forward.

You have two tools for this kind of forwarding — a Route Handler like the one above, when you need custom validation logic in the middle, or plain rewrites in next.config.js, when you just need to transparently redirect matching paths to another origin with no logic involved. Reach for rewrites first; only build a custom Route Handler proxy when you need to inspect or modify the request along the way.

NextRequest and NextResponse

Next.js extends the standard Web Request and Response objects with a couple of quality-of-life additions, available in both Route Handlers and Proxy. NextRequest adds a nextUrl property that gives you pre-parsed access to the pathname and search params without manually constructing a URL object, plus convenience methods for reading and writing cookies. NextResponse adds static helpers — .json(), .redirect(), .rewrite(), .next() — that save you from hand-building Response objects for the common cases.

// app/echo-pathname/route.ts
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const nextUrl = request.nextUrl;

  if (nextUrl.searchParams.get("redirect")) {
    return NextResponse.redirect(new URL("/", request.url));
  }

  if (nextUrl.searchParams.get("rewrite")) {
    return NextResponse.rewrite(new URL("/", request.url));
  }

  return NextResponse.json({ pathname: nextUrl.pathname });
}

Because these are extensions of the standard classes rather than incompatible replacements, you can pass a NextRequest anywhere a plain Request is expected, and return a NextResponse anywhere a plain Response is expected. This matters in practice because it means third-party libraries built against the standard Fetch API work fine with these types without any adapter layer — you're not locked into a Next.js-specific request/response shape.

Webhooks and Callback URLs

Two of the most common real-world reasons to build a public Route Handler: receiving webhooks from third-party services, and handling OAuth-style callback redirects.

For webhooks — say, a CMS pinging your app whenever content changes so you can invalidate a cache tag — the shape is simple: verify a shared secret, then act.

// app/webhook/route.ts
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const token = request.nextUrl.searchParams.get("token");

  if (token !== process.env.REVALIDATE_SECRET_TOKEN) {
    return NextResponse.json({ success: false }, { status: 401 });
  }

  const tag = request.nextUrl.searchParams.get("tag");
  if (!tag) {
    return NextResponse.json({ success: false }, { status: 400 });
  }

  revalidateTag(tag, "max");
  return NextResponse.json({ success: true });
}

For callback URLs — where a third-party auth provider redirects the user back to your site after they've authenticated elsewhere — the critical detail is guarding against open redirects. If your handler blindly redirects to whatever URL a query parameter says, you've built a phishing tool, not an auth flow:

// app/auth/callback/route.ts
import { type NextRequest, NextResponse } from "next/server";

export async function GET(request: NextRequest) {
  const token = request.nextUrl.searchParams.get("session_token");
  const redirectUrl = request.nextUrl.searchParams.get("redirect_url");

  const destination = new URL(redirectUrl ?? "/", request.url);

  // Prevent open redirects: only allow same-origin destinations
  if (destination.origin !== request.nextUrl.origin) {
    return new Response("Invalid redirect", { status: 400 });
  }

  const response = NextResponse.redirect(destination);
  response.cookies.set({
    value: token,
    name: "_token",
    path: "/",
    secure: true,
    httpOnly: true,
  });

  return response;
}

The origin check here isn't optional decoration — it's the entire security boundary for this endpoint. Without it, an attacker crafts a link like yoursite.com/auth/callback?redirect_url=https://evil.com and uses your own trusted domain to redirect victims somewhere malicious after they've authenticated. Any time you're building a redirect based on user-supplied input, this same-origin check is non-negotiable.

Proxy: The Gatekeeper in Front of Everything

Route Handlers are the endpoints themselves; Proxy is what runs before a request reaches one, and it's the tool for cross-cutting concerns you don't want to repeat in every handler — authentication gates, path rewriting, redirects.

// proxy.ts
import { isAuthenticated } from "@lib/auth";

export const config = {
  matcher: "/api/:function*",
};

export function proxy(request: Request) {
  if (!isAuthenticated(request)) {
    return Response.json(
      { success: false, message: "authentication failed" },
      { status: 401 },
    );
  }
}

Only one Proxy file is allowed per project, so the matcher config is how you scope it to only the paths that need it — here, everything under /api/. If you're coming from an older Next.js codebase, this file used to be called middleware.ts; the concept is identical, the file just has a new name and a new primary export function (proxy instead of middleware). Interestingly, the docs note that third-party libraries may still refer to this concept internally as "middleware" even while Next.js calls it Proxy — so don't be surprised if a library's factory function is still named createMiddleware().

Security: The Part Everyone Skims and Shouldn't

A handful of practices come up repeatedly in the official guidance, and they're worth treating as a checklist rather than prose you skim once:

Be deliberate about which headers go where. Don't reflexively forward incoming request headers straight into your response. If you need to modify headers your own server receives (say, injecting an internal auth token before forwarding to an upstream service), do it through NextResponse.next({ request: { headers } }) inside Proxy — that modification is invisible to the client. If you set headers on the actual response object, they're visible to whoever made the request, full stop.

Rate limit deliberately, at more than one layer. Code-level checks are good, but they're not a substitute for whatever rate limiting your hosting provider offers at the infrastructure level. Layer both.

Never trust the payload. Validate content type and size before processing, sanitize anything that touches HTML or XML output, and set timeouts so a slow or malicious client can't hold a handler open indefinitely. If users are uploading files, don't route large binary payloads through your Route Handler at all — have the browser upload directly to a storage service and just persist the resulting URI in your own database. That keeps your API's request bodies small and avoids the memory pressure of buffering large uploads server-side.

Authentication belongs in the handler, not just in Proxy. Proxy is a good first gate, but treating it as your only line of defense is a mistake — always verify credentials at the point where a protected action actually happens, in case a request somehow reaches a Route Handler without passing through Proxy (a misconfigured matcher, a direct internal call, whatever the cause).

Preflight Requests and CORS

If your API is meant to be called from a different origin than the one it's hosted on (a mobile app talking to api.yoursite.com, or a separate frontend at a different domain), browsers will send an OPTIONS preflight request before the real one, asking permission based on origin, method, and headers. If you haven't defined an OPTIONS handler yourself, Next.js adds one automatically and sets the Allow header based on whichever methods you did define — which is a nice bit of default behavior that saves you from a class of CORS bugs on day one, though for anything beyond the simplest same-origin case you'll still want to configure CORS headers explicitly.

Caveats Worth Knowing Before You Commit to This Pattern

A few limitations aren't obvious until you hit them in production, and they change how you should architect around this pattern from the start.

Don't call your own Route Handlers from Server Components. This is probably the single most common mistake developers make when they first discover Route Handlers exist. If a Server Component is prerendered at build time, fetching from your own Route Handler will fail outright — there's no server listening for that request during the build. Even for Server Components rendered on demand at runtime, going through a Route Handler is strictly slower than fetching your data source directly, because you're adding a full extra HTTP round trip (Server Component → your own Route Handler → actual data source, and back). Route Handlers exist for external clients — mobile apps, other services, browsers making client-side requests. If a Server Component needs data, fetch it directly from the source, not through your own API.

Server Actions are queued, not parallel. If you're tempted to use a Server Action purely as a data-fetching mechanism because it's convenient, know that Server Actions execute sequentially — using them for fetching, rather than mutating, introduces avoidable serial execution where parallel Route Handler or direct-fetch calls would be faster.

output: 'export' mode changes the rules entirely. A static export produces no runtime server at all, so only GET Route Handlers survive, and only when explicitly marked force-static. This is enough to generate static JSON, TXT, or other files at build time, but nothing dynamic works in that mode — no live database queries, no per-request logic.

Serverless deployment has real constraints. Many hosts deploy Route Handlers as individual lambda-style functions, which means: no shared in-memory state between requests (each invocation could be a cold, isolated instance), potentially no writable filesystem, hard timeouts on long-running handlers, and no WebSockets, since the underlying connection gets torn down the moment a response is sent or a timeout hits. If your BFF layer needs persistent connections or shared server-side state, that's a sign you need a dedicated long-running backend process alongside Next.js, not a replacement for it.

Key Takeaways

ScenarioTool
Public API endpoint, any content typeRoute Handler (route.ts)
Gatekeeping before a request reaches a routeProxy (proxy.ts)
Same URL, different format by Accept headerrewrites with header matching + Route Handler
Forwarding to an existing backendRoute Handler proxy, or plain rewrites for simple cases
Receiving webhooks / OAuth callbacksRoute Handler with strict secret/origin validation
Fetching data for a Server ComponentFetch the source directly — never your own Route Handler
Static-only deploymentGET-only Route Handlers with dynamic = 'force-static'
Persistent connections, shared state, background jobsA separate dedicated backend service

Used deliberately, this pattern turns Next.js into a genuinely capable API layer — not a full backend replacement, but a fast, serverless-friendly place to validate, aggregate, and shape data before it reaches whatever frontend you're serving. The main discipline it demands is knowing where the pattern's edges are: keep Server Components fetching directly from their sources, keep authentication checks inside the handler itself rather than relying solely on Proxy, and treat the serverless deployment model as a real architectural constraint rather than an implementation detail you can ignore.

Tags :
Share :

Related Posts

Can Next.js Be Used with GraphQL?

Can Next.js Be Used with GraphQL?

Next.js and GraphQL are two powerful technologies that have gained significant traction in the web development community. Next.js, a React-based fram

Dive Deeper
How does Next.js differ from Create React App?

How does Next.js differ from Create React App?

In the world of modern web development, React.js has emerged as a dominant force due to its flexibility, performance, and extensive ecosystem. Two po

Dive Deeper
How does Next.js handle image optimization?

How does Next.js handle image optimization?

In modern web development, image optimization plays a critical role in enhancing user experience and improving site performance. Large, unoptimized i

Dive Deeper