Complete guide to building APIs with Next.js 16 — when to use Server Actions, when to use Route Handlers, request validation, error handling, and authentication patterns.
| Feature | Server Actions | Route Handlers |
|---|---|---|
| Best for | Form submissions, mutations | External APIs, webhooks |
| Invoked by | Client components (forms) | HTTP requests (fetch, curl) |
| Progressive enhancement | ✅ Works without JS | ❌ Requires JS |
| Caching | Automatic revalidation | Manual caching |
| Type safety | End-to-end with TypeScript | Request/response types |
| File location | src/actions/*.ts | src/app/api/** |
Rule of thumb: Use Server Actions for your own app's mutations. Use Route Handlers for external integrations, webhooks, and data APIs.
// src/actions/product.actions.ts
"use server";
z
// src/app/api/products/route.ts
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
// GET /api/products
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "20");
const category = searchParams.get("category");
const where = {
published: true,
deletedAt: null,
...(category && { category: { slug: category } }),
};
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
include: { category: true, images: { take: 1 } },
orderBy: { createdAt: "desc" },
}),
prisma.product.count({ where }),
]);
return NextResponse.json({
data: products,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
});
}
// POST /api/products — for external integrations
export async function POST(request: NextRequest) {
// Validate API key
const apiKey = request.headers.get("x-api-key");
if (!apiKey || !(await validateApiKey(apiKey))) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const body = await request.json();
// ... create product logic
}
// src/app/api/products/[id]/route.ts
import { NextRequest, NextResponse } from "next/server";
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const product = await prisma.product.findUnique({
where: { id, deletedAt: null },
include: { category: true, images: true, reviews: true },
});
if (!product) {
return NextResponse.json(
{ error: "Product not found" },
{ status: 404 }
);
}
return NextResponse.json({ data: product });
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const body = await request.json();
// ... update logic with validation
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
// Soft delete
await prisma.product.update({
where: { id },
data: { deletedAt: new Date() },
});
return NextResponse.json({ message: "Deleted" });
}
Our platform uses an internal API for middleware communication:
// src/app/api/system/middleware-state/route.ts
export async function GET() {
const [userCount, siteConfig] = await Promise.all([
prisma.user.count({ where: { deletedAt: null } }),
prisma.siteConfig.findMany({
where: { key: { startsWith: "feature_" } },
}),
]);
return NextResponse.json({
userCount,
features: Object.fromEntries(
siteConfig.map((c) => [c.key, c.value])
),
maintenance: siteConfig.find(
(c) => c.key === "maintenance_mode"
)?.value === "true",
});
}
This avoids Edge-incompatible database calls in middleware. Read more in our Next.js 16 Complete Guide.
// src/app/api/cron/cleanup/route.ts
export async function GET(request: NextRequest) {
// Authenticate cron requests
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
// Cleanup old soft-deleted records (90+ days)
const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000);
const deleted = await prisma.notification.deleteMany({
where: {
deletedAt: { lt: cutoff },
},
});
return NextResponse.json({
message: `Cleaned ${deleted.count} records`,
});
}
// Consistent error response format
function apiError(message: string, status: number, details?: unknown) {
return NextResponse.json(
{
error: message,
status,
...(process.env.NODE_ENV === "development" && { details }),
},
{ status }
);
}
// Usage
export async function GET(request: NextRequest) {
try {
const data = await fetchData();
return NextResponse.json({ data });
} catch (error) {
if (error instanceof Prisma.PrismaClientKnownRequestError) {
if (error.code === "P2025") {
return apiError("Record not found", 404);
}
}
return apiError("Internal server error", 500, error);
}
}
Our Developer Portfolio & SaaS Platform uses this architecture:
Client Components → Server Actions → Prisma → Database
(forms, mutations) (25+ files)
External Services → Route Handlers → Prisma → Database
(webhooks, APIs) (api/ routes)
Cron Jobs → Cron Endpoints → Prisma → Database
(scheduled tasks) (api/cron/)
Android Apps → Socket.IO Server → Prisma → Database
(HK Relay, HK AIR) (WebSocket)
Related reads:
25+ Server Action files, REST APIs, WebSocket endpoints — all production-ready in our Developer Portfolio SaaS. Starting at $299.
Get the latest articles, tutorials, and updates delivered straight to your inbox. No spam, unsubscribe at any time.