next.js

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.

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