Back to blog
web development

Build the API with Next.js only

·10 min read
Piotr
Piotr
Founder
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

typescript
1import { NextResponse } from "next/server";
2
3// GET /api/users
4export async function GET() {
5 const users = [
6 { id: 1, name: "Alice" },
7 { id: 2, name: "Bob" },
8 ];
9
10 return NextResponse.json(users, { status: 200 });
11}
12
13// POST /api/users
14export 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 }
21
22 // 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 list
  • POST /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().

typescript
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:

typescript
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.nextUrl directly (no manual new 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:

sh
1app
2└── api
3 └── users
4 └── [id]
5 └── route.ts

app/api/users/[id]/route.ts

typescript
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:

  • params is not a Promise—you don’t await it. 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/users or deeper paths
Tip: Validate params.id (e.g., ensure it’s a UUID/number) before touching your database, and return 400 for invalid ids, 404 for 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.

typescript
1// app/api/external/route.ts
2import { NextRequest, NextResponse } from "next/server";
3
4const UPSTREAM = "https://example.com/api/data";
5
6// GET /api/external?query=hello
7export async function GET(request: NextRequest) {
8 // Forward query string to upstream
9 const url = new URL(UPSTREAM);
10 request.nextUrl.searchParams.forEach((v, k) => url.searchParams.set(k, v));
11
12 const upstream = await fetch(url, {
13 headers: {
14 // Forward only what you need; avoid blindly proxying all headers
15 Authorization: `Bearer ${process.env.API_TOKEN!}`,
16 "X-Client": "next-bff",
17 },
18 cache: "no-store", // proxies are usually dynamic
19 });
20
21 // Gracefully pass through upstream status
22 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 }
29
30 // Optional transform before returning
31 const transformed = { ...payload, source: "proxied-through-nextjs" };
32 return NextResponse.json(transformed);
33}
34
35// POST /api/external
36export async function POST(request: NextRequest) {
37 const body = await request.json();
38
39 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 });
48
49 // Pass through status; keep body JSON
50 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" (or next: { revalidate: 0 }) for dynamic proxies.
  • Timeouts: Consider an AbortController to 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 from middleware.ts (the Edge middleware that runs before routing). Use middleware.ts for 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

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

typescript
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:


typescript
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

About the author

Piotr
Piotr
Founder

Results-driven and forward-thinking Senior Leader in Payments, Banking & Finance with expertise in AI, Full Stack Development, and Python programming.