next.js

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.

Quando usar cada um
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 Handler

Route Handlers : estrutura base

📝 Nota

Os exemplos abaixo são documentação de referência. Eles não estão ativos neste site estático.
app/api/health/route.ts
// 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",
  });
}
app/api/users/[id]/route.ts
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

app/api/posts/route.ts
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 });
}
app/api/posts/[id]/route.ts
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

app/api/contact/route.ts (com Zod)
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

src/lib/api-auth.ts
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.

Estrutura de resposta padronizada
// 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ção

Cache e revalidação

cache em Route Handlers
// 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

CORS em Route Handlers
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.

rate-limit conceitual (com Upstash Redis)
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

src/lib/logger.ts
// 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)
next.config.ts (este site)
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