
Build the API with Next.js only

Piotr
Founder
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
1
2import { NextResponse } from "next/server";
3
4// GET /api/users
5export async function GET() {
6 const users = [
7 { id: 1, name: "Alice" },
8 { id: 2, name: "Bob" },
9 ];
10
11 return NextResponse.json(users, { status: 200 });
12}
13
14// POST /api/users
15export async function POST(request: Request) {
16 try {
17 const body = await request.json();
18 const name = String(body?.name ?? "").trim();
19 if (!name) {
20 return NextResponse.json({ error: "Name is required" }, { status: 400 });
21 }
22
23 // In real apps, create in DB instead of Date.now()
24 const newUser = { id: Date.now(), name };
25 return NextResponse.json(newUser, { status: 201 });
26 } catch {
27 return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
28 }
29}
30How 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.ts
2import { NextRequest, NextResponse } from "next/server";
3
4export function GET(request: NextRequest) {
5 const query = request.nextUrl.searchParams.get("query") ?? ""; // /api/search?query=hello
6 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.ts
2export 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:
1
2app
3└── api
4 └── users
5 └── [id]
6 └── route.ts
7 app/api/users/[id]/route.ts
1import { NextRequest, NextResponse } from "next/server";
2
3type RouteContext = { params: { id: string } };
4
5// GET /api/users/123
6export async function GET(_req: NextRequest, { params }: RouteContext) {
7 const { id } = params;
8
9 // In real apps: fetch from DB by `id`
10 const user = { id, name: `User ${id}` };
11
12 // Example not-found branch:
13 // if (!user) return NextResponse.json({ error: "User not found" }, { status: 404 });
14
15 return NextResponse.json(user, { status: 200 });
16}
17
18// DELETE /api/users/123
19export async function DELETE(_req: NextRequest, { params }: RouteContext) {
20 const { id } = params;
21
22 // 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 });
24
25 return new NextResponse(null, { status: 204 }); // No Content
26}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
2// app/api/external/route.ts
3import { NextRequest, NextResponse } from "next/server";
4
5const UPSTREAM = "https://example.com/api/data";
6
7// GET /api/external?query=hello
8export async function GET(request: NextRequest) {
9 // Forward query string to upstream
10 const url = new URL(UPSTREAM);
11 request.nextUrl.searchParams.forEach((v, k) => url.searchParams.set(k, v));
12
13 const upstream = await fetch(url, {
14 headers: {
15 // Forward only what you need; avoid blindly proxying all headers
16 Authorization: `Bearer ${process.env.API_TOKEN!}`,
17 "X-Client": "next-bff",
18 },
19 cache: "no-store", // proxies are usually dynamic
20 });
21
22 // Gracefully pass through upstream status
23 const payload = await upstream.json().catch(() => null);
24 if (!upstream.ok) {
25 return NextResponse.json(
26 { error: "Upstream error", status: upstream.status, payload },
27 { status: upstream.status }
28 );
29 }
30
31 // Optional transform before returning
32 const transformed = { ...payload, source: "proxied-through-nextjs" };
33 return NextResponse.json(transformed);
34}
35
36// POST /api/external
37export async function POST(request: NextRequest) {
38 const body = await request.json();
39
40 const upstream = await fetch(UPSTREAM, {
41 method: "POST",
42 headers: {
43 Authorization: `Bearer ${process.env.API_TOKEN!}`,
44 "Content-Type": "application/json",
45 },
46 body: JSON.stringify(body),
47 cache: "no-store",
48 });
49
50 // Pass through status; keep body JSON
51 const payload = await upstream.json().catch(() => ({}));
52 return NextResponse.json(payload, { status: upstream.status });
53}
54Key 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";
2
3type RouteContext = { params?: Record<string, string> };
4type Handler = (req: NextRequest, ctx: RouteContext) => Response | Promise<Response>;
5
6export function withAuth<T extends Handler>(handler: T): Handler {
7 return async (req, ctx) => {
8 const token = req.cookies.get("token")?.value;
9
10 // 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 }
14
15 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 };
3
4export default async function UsersPage() {
5 const res = await fetch("https://api.example.com/users", {
6 // choose one strategy:
7 // cache: "no-store", // always fresh
8 next: { revalidate: 60 }, // ISR: revalidate every 60s
9 });
10
11 if (!res.ok) {
12 // keep error handling server-side
13 throw new Error("Failed to load users");
14 }
15
16 const data = (await res.json()) as User[];
17
18 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.tsx
2import { listUsers } from "@/lib/repositories/user.repo"; // your DAL
3
4export 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