Next.js Server API
Documentación y ejemplos de Route Handlers, Server Actions y Server Components.
⚠️ Atención: este sitio usa output: "export" los ejemplos son documentación/referencia y no están activos en el deploy estático.
Para un backend real, crea un servicio separado (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