Next.js Server API
Documentation and examples of Route Handlers, Server Actions and Server Components.
⚠️ Note: this site uses output: "export" the examples below are documentation/reference only and are not active in the static deploy.
For a real backend, create a separate service (Express, Fastify, Lambda, etc.).
Server Components vs Server Actions vs Route Handlers
Server Components
Componentes React renderizados no servidor. Podem acessar banco de dados, APIs privadas e filesystem diretamente. Não são endpoints HTTP : são renderização de UI no servidor.
Server Actions
Funções assíncronas marcadas com 'use server'. Permitem que formulários e Client Components chamem código no servidor diretamente, sem precisar criar uma API manualmente. São chamadas via POST automático pelo React.
Route Handlers
Arquivos route.ts em app/api/**. São endpoints HTTP reais que respondem a qualquer cliente (mobile app, terceiros, curl). Use quando precisa de uma API pública ou que será consumida por outros serviços.
Server Component → buscar dados para renderizar UI (prefira sempre)
Server Action → mutações disparadas de formulários ou Client Components
Route Handler → API pública, webhooks, integrações com clientes externos
Regra prática:
Se é só para a UI do Next.js → Server Component + Server Action
Se outros clientes vão consumir → Route HandlerRoute Handlers : estrutura base
📝 Nota
// Endpoint de health check : sem autenticação
export const dynamic = "force-dynamic"; // necessário para dados dinâmicos
export async function GET() {
return Response.json({
ok: true,
service: "marcocianci.com",
timestamp: new Date().toISOString(),
version: process.env.APP_VERSION ?? "0.0.0",
});
}import { NextRequest } from "next/server";
// GET /api/users/:id
export async function GET(
_request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
if (!isUUID(id)) {
return Response.json({ ok: false, error: "invalid_id" }, { status: 400 });
}
const user = await db.users.findById(id);
if (!user) {
return Response.json({ ok: false, error: "user_not_found" }, { status: 404 });
}
return Response.json({ ok: true, data: user });
}
// DELETE /api/users/:id
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const caller = await requireAuth(request);
if (caller.id !== id && caller.role !== "admin") {
return Response.json({ ok: false, error: "forbidden" }, { status: 403 });
}
await db.users.delete(id);
return new Response(null, { status: 204 });
}
function isUUID(v: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
}GET, POST, PUT, PATCH, DELETE
import { NextRequest } from "next/server";
// GET : listar com paginação e filtros
export async function GET(request: NextRequest) {
const { searchParams } = request.nextUrl;
const page = Math.max(1, Number(searchParams.get("page") ?? 1));
const limit = Math.min(100, Math.max(1, Number(searchParams.get("limit") ?? 20)));
const q = searchParams.get("q") ?? "";
const { data, total } = await db.posts.findMany({ page, limit, q });
return Response.json({
ok: true,
data,
meta: { page, limit, total, pages: Math.ceil(total / limit) },
});
}
// POST : criar recurso
export async function POST(request: NextRequest) {
const caller = await requireAuth(request);
const body = await parseBody<CreatePostInput>(request);
if (!body.ok) return body.error; // resposta de erro já formatada
const post = await db.posts.create({ ...body.data, authorId: caller.id });
return Response.json({ ok: true, data: post }, { status: 201 });
}import { NextRequest } from "next/server";
// PUT : substituição total
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const caller = await requireAuth(request);
const body = await parseBody<UpdatePostInput>(request);
if (!body.ok) return body.error;
await ensureOwnership(caller, id);
const post = await db.posts.replace(id, body.data);
return Response.json({ ok: true, data: post });
}
// PATCH : atualização parcial
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const caller = await requireAuth(request);
const body = await request.json() as Partial<UpdatePostInput>;
await ensureOwnership(caller, id);
const post = await db.posts.update(id, body);
return Response.json({ ok: true, data: post });
}
// DELETE : remoção de recurso
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const caller = await requireAuth(request);
await ensureOwnership(caller, id);
await db.posts.delete(id);
return new Response(null, { status: 204 });
}Validação de payload
import { z } from "zod";
import { NextRequest } from "next/server";
const ContactSchema = z.object({
name: z.string().min(2).max(100).trim(),
email: z.string().email().toLowerCase(),
message: z.string().min(10).max(2000),
subject: z.string().max(200).optional(),
});
type ContactPayload = z.infer<typeof ContactSchema>;
export async function POST(request: NextRequest) {
let rawBody: unknown;
try {
rawBody = await request.json();
} catch {
return Response.json(
{ ok: false, error: "invalid_json" },
{ status: 400 }
);
}
const result = ContactSchema.safeParse(rawBody);
if (!result.success) {
return Response.json(
{
ok: false,
error: "validation_failed",
details: result.error.flatten().fieldErrors,
},
{ status: 422 }
);
}
const { name, email, message }: ContactPayload = result.data;
await sendEmail({ to: "[email protected]", from: email, subject: `Contato: ${name}`, body: message });
return Response.json({ ok: true }, { status: 201 });
}Autenticação e autorização
import { NextRequest } from "next/server";
import { verifyToken, TokenPayload } from "./auth";
// Helper de autenticação para Route Handlers
export async function requireAuth(request: NextRequest): Promise<TokenPayload> {
const auth = request.headers.get("Authorization");
if (!auth?.startsWith("Bearer ")) {
throw new ApiError("missing_token", 401);
}
try {
return verifyToken(auth.slice(7));
} catch {
throw new ApiError("invalid_token", 401);
}
}
// Helper de autorização por role
export async function requireRole(request: NextRequest, role: string): Promise<TokenPayload> {
const user = await requireAuth(request);
if (user.role !== role) throw new ApiError("forbidden", 403);
return user;
}
// Classe de erro customizada
export class ApiError extends Error {
constructor(
public readonly code: string,
public readonly status: number,
message?: string
) {
super(message ?? code);
}
toResponse() {
return Response.json({ ok: false, error: this.code }, { status: this.status });
}
}
// Wrapper para tratamento automático de erros
export function withErrorHandling(
handler: (req: NextRequest, ctx: unknown) => Promise<Response>
) {
return async (req: NextRequest, ctx: unknown) => {
try {
return await handler(req, ctx);
} catch (err) {
if (err instanceof ApiError) return err.toResponse();
console.error("[API Error]", err);
return Response.json({ ok: false, error: "internal_error" }, { status: 500 });
}
};
}Erros padronizados
Respostas de erro consistentes facilitam o consumo da API e o debug.
// Sucesso
{ "ok": true, "data": { ... } }
{ "ok": true, "data": [...], "meta": { "page": 1, "total": 42 } }
// Erro de validação (422)
{
"ok": false,
"error": "validation_failed",
"details": {
"email": ["Email inválido"],
"name": ["Mínimo 2 caracteres"]
}
}
// Erro de autenticação (401)
{ "ok": false, "error": "invalid_token" }
// Não encontrado (404)
{ "ok": false, "error": "user_not_found" }
// Erro interno (500)
{ "ok": false, "error": "internal_error" }
// Nunca exponha stack trace ou detalhes internos em produçãoCache e revalidação
// Cache controlado por headers de resposta
export async function GET() {
const data = await fetchPublicData();
return Response.json({ data }, {
headers: {
"Cache-Control": "public, s-maxage=3600, stale-while-revalidate=86400",
// s-maxage: CDN caches for 1 hour
// stale-while-revalidate: serve stale for 24h while revalidating
},
});
}
// Sem cache : dados sempre frescos
export const dynamic = "force-dynamic"; // ou
export async function GET() {
return Response.json({ ts: Date.now() }, {
headers: { "Cache-Control": "no-store" },
});
}
// Revalidação on-demand (tag-based)
import { revalidateTag } from "next/cache";
export async function POST(request: NextRequest) {
await updateProduct(await request.json());
revalidateTag("products"); // invalida todos os fetches com esta tag
return Response.json({ ok: true });
}CORS
const ALLOWED_ORIGINS = [
"https://marcocianci.com",
"https://app.marcocianci.com",
...(process.env.NODE_ENV === "development" ? ["http://localhost:3023"] : []),
];
function corsHeaders(origin: string | null): HeadersInit {
const allowed = origin && ALLOWED_ORIGINS.includes(origin) ? origin : "";
return {
"Access-Control-Allow-Origin": allowed,
"Access-Control-Allow-Methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization",
"Access-Control-Max-Age": "86400",
};
}
// Preflight OPTIONS
export async function OPTIONS(request: NextRequest) {
const origin = request.headers.get("origin");
return new Response(null, {
status: 204,
headers: corsHeaders(origin),
});
}
export async function GET(request: NextRequest) {
const origin = request.headers.get("origin");
const data = await fetchData();
return Response.json({ data }, {
headers: corsHeaders(origin),
});
}Rate limiting : conceitual
Rate limiting protege a API contra abuso. Em Next.js stateless, use Redis ou um serviço de edge rate limiting.
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
import { NextRequest } from "next/server";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"), // 10 req / 10 segundos
});
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "anonymous";
const { success, remaining, reset } = await ratelimit.limit(ip);
if (!success) {
return Response.json(
{ ok: false, error: "rate_limit_exceeded" },
{
status: 429,
headers: {
"Retry-After": String(Math.ceil((reset - Date.now()) / 1000)),
"X-RateLimit-Remaining": String(remaining),
},
}
);
}
// continua processamento...
}Logs e observabilidade
// Logger estruturado simples
type LogLevel = "debug" | "info" | "warn" | "error";
type LogEntry = {
level: LogLevel;
message: string;
timestamp: string;
[key: string]: unknown;
};
function log(level: LogLevel, message: string, meta?: Record<string, unknown>) {
const entry: LogEntry = {
level,
message,
timestamp: new Date().toISOString(),
...meta,
};
// Em produção: envie para Datadog, CloudWatch, etc.
// Em dev: console legível
if (process.env.NODE_ENV === "production") {
console[level](JSON.stringify(entry));
} else {
console[level](`[${entry.level.toUpperCase()}] ${message}`, meta ?? "");
}
}
export const logger = {
debug: (msg: string, meta?: Record<string, unknown>) => log("debug", msg, meta),
info: (msg: string, meta?: Record<string, unknown>) => log("info", msg, meta),
warn: (msg: string, meta?: Record<string, unknown>) => log("warn", msg, meta),
error: (msg: string, meta?: Record<string, unknown>) => log("error", msg, meta),
};
// Uso em Route Handler
export async function POST(request: NextRequest) {
const startTime = Date.now();
const requestId = crypto.randomUUID();
try {
const data = await processRequest(request);
logger.info("request.success", { requestId, duration: Date.now() - startTime });
return Response.json({ ok: true, data });
} catch (err) {
logger.error("request.failed", { requestId, error: String(err) });
return Response.json({ ok: false, error: "internal_error" }, { status: 500 });
}
}Quando NÃO usar Route Handler
- Buscar dados para renderizar UI: use Server Component com fetch/db direto : mais simples e sem overhead de HTTP.
- Mutações de formulário: use Server Actions : sem boilerplate de fetch no cliente.
- Lógica de negócio interna: crie funções TypeScript normais, não endpoints.
- Redirecionar usuários: use
redirect()em Server Components ou middleware.
Use Route Handler quando:
- A API será consumida por clients externos (mobile, terceiros)
- Você precisa de um webhook (Stripe, GitHub, etc.)
- Precisa de controle total sobre headers HTTP
- Integrações que exigem endpoints REST públicos
Limitações do output: "export"
🚨 Funcionalidades incompatíveis com output: export
As seguintes funcionalidades do Next.js requerem runtime Node.js e não funcionam em deploy estático.
Não disponível em deploy estático:
- Route Handlers (
app/api/**/route.ts) : não geram arquivos - Server Actions (
'use server') : requerem servidor revalidatePath/revalidateTag: requerem runtime- Middleware : não executa em exportação estática
- Image Optimization : use
images.unoptimized: true - Streaming / Suspense com dados dinâmicos : gera HTML estático
- Cookies e Headers dinâmicos no servidor
Solução para backend real:
Mantenha o site estático (Next.js + S3 + CloudFront) e crie um serviço separado para a API:
- Express / Fastify rodando em ECS Fargate ou EC2
- AWS Lambda + API Gateway (serverless)
- Next.js separado com runtime Node.js (não estático)
import type { NextConfig } from "next";
import createNextIntlPlugin from "next-intl/plugin";
const withNextIntl = createNextIntlPlugin("./i18n/request.ts");
const nextConfig: NextConfig = {
output: "export", // 100% estático : sem servidor Node.js em produção
images: {
unoptimized: true, // necessário para exportação estática
},
};
export default withNextIntl(nextConfig);
// Deploy: npm run build → ./out → aws s3 sync → CloudFront