Build the API with Next.js only


With Next.js 15, you can build a full-featured API without a separate backend by combining Route Handlers for REST endpoints, Server Actions for secure mutations, and Middleware for cross-cutting concerns. This keeps your data on the server, taps into built-in caching and revalidation, and deploys as scalable serverless/edge functions by default.
Why (and When) to Build APIs with Next.js
1 - Public API for Multiple Client
Expose a shared API that serves your Next.js web app, a mobile app, or approved third-party consumers. For example, both your React website and React Native app can fetch from /api/users.
2 - Proxy to an Existing Backend
Use Route Handlers as thin proxy to consolidate microservices behind a single endpoint. Intercept requests to add auth, validation, rate limiting, or response shaping, then forward to the upstream service.
3 - Webhooks and Integrations
Handle incoming webhooks (e.g. Stripe, GitHub, Twilio) directly in Route Handlers. Verify signatures, parse payloads, and trigger downstream work (DB writes, queues, emails).
4 - Custom Authentication
Implement sessions or tokens in your API layer. Read headers/cookies, issue or rotate tokens, and return user/session data. Pair with Middleware for cross-cutting checks.
Important: If data is used only by your own Next.js app, you may not need a separate API. Server Components can fetch data during render, and Server Actions can handle mutations—keeping logic server-side without extra endpoints.
Multiple HTTP Methods in One File
In the App Router, a single route file can handle multiple HTTP methods. Instead of one default export, you export one function per method (e.g. GET, POST, PATCH, DELETE) from the same file:
app/api/users/route.ts
1import { NextResponse } from "next/server";23// GET /api/users4export async function GET() {5 const users = [6 { id: 1, name: "Alice" },7 { id: 2, name: "Bob" },8 ];910 return NextResponse.json(users, { status: 200 });11}1213// POST /api/users14export async function POST(request: Request) {15 try {16 const body = await request.json();17 const name = String(body?.name ?? "").trim();18 if (!name) {19 return NextResponse.json({ error: "Name is required" }, { status: 400 });20 }2122 // In real apps, create in DB instead of Date.now()23 const newUser = { id: Date.now(), name };24 return NextResponse.json(newUser, { status: 201 });25 } catch {26 return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });27 }28}
How it works
GET /api/users-> returns the users listPOST /api/users=> with{"name": "Charlie"}-> creates and returns a new user
Tip: You can add more handlers (PATCH, DELETE, OPTIONS for CORS) in the same file.
Working with Web APIs
In Route Handlers, your method functions (GET, POST, etc.) receive a request and must return a response. You can use the Web-standard Request/Response, or Next.js helpers NextRequest/NextResponse.
OPTION A - Use NextRequest + NextResponse (typed & convenient)
NextRequest extends the Web Request with Next.js niceties like nextUrl (a parsed URL) and typed cookies helper. NextResponse provides shortcuts like json().
1// app/api/search/route.ts2import { NextRequest, NextResponse } from "next/server";34export function GET(request: NextRequest) {5 const query = request.nextUrl.searchParams.get("query") ?? ""; // /api/search?query=hello6 return NextResponse.json({ result: `You searched for: ${query}` });7}
OPTION B - use plain Web Request/Response (standards-only)
You don't have to use NextRequest. With the standard Request, parse the URL yourself:
1// app/api/search/route.ts2export function GET(request: Request) {3 const { searchParams } = new URL(request.url);4 const query = searchParams.get("query") ?? "";5 return new Response(JSON.stringify({ result: `You searched for: ${query}` }), {6 headers: { "Content-Type": "application/json" },7 });8}
Why important NextRequest?
- Type safety & DX: Strong typing for
request(better IntelliSense/autocomplete). - Convenience: Access
request.nextUrldirectly (no manualnew URL(...)) - Next.js features: Typed helpers (e.g.,
request.cookies) and integration with Next.js runtimes.
Dynamic Routes
To create dynamic paths (e.g. /api/users/:id), use Dynamic Segments in your folder structure:
1app2└── api3 └── users4 └── [id]5 └── route.ts
app/api/users/[id]/route.ts
1import { NextRequest, NextResponse } from "next/server";23type RouteContext = { params: { id: string } };45// GET /api/users/1236export async function GET(_req: NextRequest, { params }: RouteContext) {7 const { id } = params;89 // In real apps: fetch from DB by `id`10 const user = { id, name: `User ${id}` };1112 // Example not-found branch:13 // if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });1415 return NextResponse.json(user, { status: 200 });16}1718// DELETE /api/users/12319export async function DELETE(_req: NextRequest, { params }: RouteContext) {20 const { id } = params;2122 // In real apps: delete in DB by `id`, handle missing records, auth, etc.23 // if (!deleted) return NextResponse.json({ error: "User not found" }, { status: 404 });2425 return new NextResponse(null, { status: 204 }); // No Content26}
Key points:
paramsis not a Promise—you don’tawaitit. It’s passed synchronously as{ params: { id: string } }.- Prefer
NextResponse.json()for JSON responses. - For more complex patterns:
- Catch-all:
app/api/users/[...slug]/route.ts→/api/users/a/b/c - Optional catch-all:
app/api/users/[[...slug]]/route.ts→/api/usersor deeper paths
- Catch-all:
Tip: Validateparams.id(e.g., ensure it’s a UUID/number) before touching your database, and return400for invalid ids,404for missing records.
Using Next.js as a Proxy / Forwarding Layer
A common pattern is using Route Handlers as a BFF (Backend-for-Frontend) - authenticate, log, or transform data, then forward to an upstream API. Your clients only call /api/external. Next.js handles the rest.
1// app/api/external/route.ts2import { NextRequest, NextResponse } from "next/server";34const UPSTREAM = "https://example.com/api/data";56// GET /api/external?query=hello7export async function GET(request: NextRequest) {8 // Forward query string to upstream9 const url = new URL(UPSTREAM);10 request.nextUrl.searchParams.forEach((v, k) => url.searchParams.set(k, v));1112 const upstream = await fetch(url, {13 headers: {14 // Forward only what you need; avoid blindly proxying all headers15 Authorization: `Bearer ${process.env.API_TOKEN!}`,16 "X-Client": "next-bff",17 },18 cache: "no-store", // proxies are usually dynamic19 });2021 // Gracefully pass through upstream status22 const payload = await upstream.json().catch(() => null);23 if (!upstream.ok) {24 return NextResponse.json(25 { error: "Upstream error", status: upstream.status, payload },26 { status: upstream.status }27 );28 }2930 // Optional transform before returning31 const transformed = { ...payload, source: "proxied-through-nextjs" };32 return NextResponse.json(transformed);33}3435// POST /api/external36export async function POST(request: NextRequest) {37 const body = await request.json();3839 const upstream = await fetch(UPSTREAM, {40 method: "POST",41 headers: {42 Authorization: `Bearer ${process.env.API_TOKEN!}`,43 "Content-Type": "application/json",44 },45 body: JSON.stringify(body),46 cache: "no-store",47 });4849 // Pass through status; keep body JSON50 const payload = await upstream.json().catch(() => ({}));51 return NextResponse.json(payload, { status: upstream.status });52}
Key points:
- Don’t leak secrets: Keep tokens in env vars; never expose them to the client.
- Headers: Forward only specific headers (auth, correlation IDs). Avoid
Host,Connection, etc. - Caching: Use
cache: "no-store"(ornext: { revalidate: 0 }) for dynamic proxies. - Timeouts: Consider an
AbortControllerto fail fast on slow upstreams. - Streaming passthrough: For large files, you can return the raw stream:
Building Shared "Middleware"-like Logic (Wrappers)
When you want to reuse logic such as auth checks, logging, or rate limiting across many Route Handlers, create higher-order handlers (wrappers) that decorate your method functions.
Note: This is different frommiddleware.ts(the Edge middleware that runs before routing). Usemiddleware.tsfor cross-cutting concerns like redirects/CORS; use wrappers when you need full request access (body, DB calls) inside the route handler.
Reusable auth wrapper: lib/http/with-auth.ts
1import { NextRequest, NextResponse } from "next/server";23type RouteContext = { params?: Record<string, string> };4type Handler = (req: NextRequest, ctx: RouteContext) => Response | Promise<Response>;56export function withAuth<T extends Handler>(handler: T): Handler {7 return async (req, ctx) => {8 const token = req.cookies.get("token")?.value;910 // TODO: verify token properly (e.g., JWT signature/JWKS or a session lookup)11 if (!token) {12 return NextResponse.json({ error: "Unauthorized" }, { status: 401 });13 }1415 return handler(req, ctx);16 };17}
When to Skip Creating an API Endpoint
With the App Router's React Server Components (RSC) you can fetch data directly on the server during render - no public endpoint needed.
1// app/users/page.tsx (Server Component)2type User = { id: number; name: string };34export default async function UsersPage() {5 const res = await fetch("https://api.example.com/users", {6 // choose one strategy:7 // cache: "no-store", // always fresh8 next: { revalidate: 60 }, // ISR: revalidate every 60s9 });1011 if (!res.ok) {12 // keep error handling server-side13 throw new Error("Failed to load users");14 }1516 const data = (await res.json()) as User[];1718 return (19 <main>20 <h1>Users</h1>21 <ul>22 {data.map((user) => ( {/* fixed: user, not users */}23 <li key={user.id}>{user.name}</li>24 ))}25 </ul>26 </main>27 );28}
If your data is only used inside your Next.js app, you often don't need a separate API. You can even skip HTTP entirely and query your database/services directly in the Server Component:
1// app/users/page.tsx2import { listUsers } from "@/lib/repositories/user.repo"; // your DAL34export default async function UsersPage() {5 const users = await listUsers(); // direct server-side call (no HTTP hop)6 return (7 <main>8 <h1>Users</h1>9 <ul>10 {users.map((u) => (11 <li key={u.id}>{u.name}</li>12 ))}13 </ul>14 </main>15 );16}
Key points:
- Use RSC fetch / direct DB calls for internal reads.
- Use Server Actions for internal writes (mutations).
- Add public API routes only when you must share data with external clients (mobile apps, partners) or need a BFF/proxy boundary
Summary
Create a new Next.js project with API boilerplate: npx create-next-app@latest --api
or add Route Handlers yourself under app/ directory (e.g. app/api/users/route.ts)
Export HTTP methods (GET, POST, PUT, DELETE, etc.) from the same file.
Use Web APIs to read the request and return a response Request/Response - or use NextReques/NextResponse for DX helpers like nextUrl, cookies, and json().
Build a public API when you need other clients (mobile/partners) to consume your data, or when proxying an existing backend (BFF pattern).
Fetch your API routes from the client (Client Components or fetch('/api/...').
Skip creating an API when a Server Component can fetch data directly on the server for your own app.
Extract shared "middleware-like" wrappers (e.g. withAuth() / withLogger()) for repeated logic across handlers.
Deploy on a Node.js-capable runtime for server features (or export statically if you only need a SPA).
What this unlocks?
- Build a public API for web, mobile, and third parties.
- Proxy/transform calls to external services with a BFF layer.
- Share reusable wrappers for auth, logging, rate limiting, etc.
- Use dynamic routing via segment folders like
[id].
Server Actions (where they fit)
Think of Server Actions as call-from-React functions that run on the server and are ideal for mutations (create/update/delete). You invoke them like normal JS functions from your components - no manual fetch or API route needed.
- There's still a network request under the hood, but you don't manage it yourself.
- The action is exposed under an opaque, framework-managed endpoint, not intended for manual calling.
- For shared logic, put your core reads/writes in Data Access Layer (DAL) and call the same DAL from both Server Actions and API routes. This keeps behavior consistent whether you expose a public API or not.
Happy coding :)
Piotr

