| name | api-layer-patterns |
| description | Enforce the internal API proxy layer pattern. Use when adding API endpoints, fetching data from backend, implementing HMAC signing, or working with lib/api/* or app/api/*. |
| license | MIT |
API Layer Patterns Guide
This skill enforces the internal API proxy layer architecture - a critical security and performance pattern in this Next.js application.
When to Use This Skill
- Adding a new API endpoint
- Fetching data from the backend
- Implementing HMAC authentication
- Working with
lib/api/* or app/api/*
- Debugging API calls or caching issues
- Implementing query builders
Core Architecture: The Proxy Pattern
CRITICAL: Never call external API directly from pages/components. Always use the three-layer pattern.
┌─────────────────────────────────────────────────────────────┐
│ Layer 1: Client Code (Pages/Components) │
│ - Calls internal API routes only │
│ - Uses getInternalApiUrl() for URL construction │
│ - No HMAC signing (server-side only) │
└─────────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 2: Internal API Routes (app/api/*) │
│ - Next.js route handlers (edge-friendly) │
│ - Call external wrappers │
│ - Set cache headers (s-maxage, stale-while-revalidate) │
│ - Return safe fallbacks on errors │
└─────────────────┬───────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Layer 3: External Wrappers (lib/api/*-external.ts) │
│ - HMAC signing via fetchWithHmac │
│ - Env guard (NEXT_PUBLIC_API_URL check) │
│ - Zod parsing for type safety │
│ - Safe fallback DTOs on failure │
│ - Call NEXT_PUBLIC_API_URL directly │
└─────────────────────────────────────────────────────────────┘
Why this pattern?
- Security: HMAC signing happens server-side only
- Caching: Internal routes set optimal cache headers
- Resilience: Graceful degradation when backend unavailable
- DRY: Shared logic in wrappers, reused across routes
Layer 1: Client Code
Fetching Data from Pages/Components
DO:
import { fetchEvents } from "@lib/api/events";
export default async function EventsPage() {
const events = await fetchEvents({ place: "barcelona" });
return <EventList events={events} />;
}
import useSWR from "swr";
import { getInternalApiUrl } from "@utils/api-helpers";
function EventsFeed() {
const { data } = useSWR(
getInternalApiUrl("/api/events", { place: "barcelona" }),
fetcher
);
return <EventList events={data?.content || []} />;
}
DON'T:
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/events`);
const signature = createHmacSignature(url, secret);
const { data } = useSWR("/api/events?place=barcelona", fetcher);
Using Query Builders
DO:
import { buildEventsQuery, buildNewsQuery } from "@utils/api-helpers";
const params = buildEventsQuery({
place: "barcelona",
byDate: "avui",
searchTerm: "jazz",
distance: 10,
lat: 41.3851,
lon: 2.1734,
});
const url = `/api/events?${params.toString()}`;
DON'T:
const params = new URLSearchParams();
params.set("place", "barcelona");
params.set("searchTerm", "jazz");
Layer 2: Internal API Routes
File Structure
app/api/
├── events/
│ ├── route.ts # GET /api/events
│ ├── [slug]/route.ts # GET /api/events/[slug]
│ └── categorized/route.ts # GET /api/events/categorized
├── news/
│ ├── route.ts # GET /api/news
│ └── [slug]/route.ts # GET /api/news/[slug]
├── categories/
│ ├── route.ts # GET /api/categories
│ └── [id]/route.ts # GET /api/categories/[id]
└── visits/
└── route.ts # POST /api/visits
Route Template
File: app/api/YOUR_RESOURCE/route.ts
import { NextRequest, NextResponse } from "next/server";
import { fetchYourResourceExternal } from "@lib/api/your-resource-external";
import { buildYourResourceQuery } from "@utils/api-helpers";
export const runtime = "edge";
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const query = buildYourResourceQuery({
param1: searchParams.get("param1") || undefined,
param2: searchParams.get("param2") || undefined,
});
const data = await fetchYourResourceExternal(query);
return NextResponse.json(data, {
headers: {
"Cache-Control": "s-maxage=600, stale-while-revalidate=3600",
},
});
}
Cache Headers Strategy
| Resource | s-maxage | stale-while-revalidate | Rationale |
|---|
| Events | 600s (10m) | 3600s (1h) | Frequently updated |
| News | 60s (1m) | 300s (5m) | Very dynamic |
| Categories | 3600s (1h) | 86400s (24h) | Rarely change |
| Sitemaps | 86400s (24h) | 604800s (7d) | Static structure |
Template:
headers: {
'Cache-Control': 's-maxage=<SECONDS>, stale-while-revalidate=<SECONDS>',
}
Per-request-billed upstreams (e.g. Google Places): CDN headers aren't enough — on Cloudflare's Free plan /api/* isn't edge-cached, and the Next fetch cache is wiped every deploy (cache-handler.mjs scopes keys by buildId). For a paid upstream, read through the deploy-independent app Redis cache (lib/cache/redis-client.ts) with a coarse key, and cap cost at the provider (GCP quota). See app/api/places/nearby/route.ts and the Places cost incident.
Layer 3: External Wrappers
External Wrapper File Structure
lib/api/
├── events-external.ts
├── news-external.ts
├── categories-external.ts
├── cities-external.ts
├── regions-external.ts
└── places-external.ts
Wrapper Template (Guarded Pattern)
File: lib/api/your-resource-external.ts
import { fetchWithHmac } from "@lib/api/fetch-wrapper";
import { z } from "zod";
import type {
YourResourceDTO,
PagedYourResourceDTO,
} from "types/api/your-resource";
const YourResourceSchema = z.object({
id: z.number(),
name: z.string(),
});
const PagedResponseSchema = z.object({
content: z.array(YourResourceSchema),
currentPage: z.number(),
pageSize: z.number(),
totalElements: z.number(),
totalPages: z.number(),
last: z.boolean(),
});
export async function fetchYourResourceExternal(
query?: URLSearchParams
): Promise<PagedYourResourceDTO> {
if (!process.env.NEXT_PUBLIC_API_URL) {
console.warn(
"[YourResource] NEXT_PUBLIC_API_URL not set, returning empty data"
);
return {
content: [],
currentPage: 0,
pageSize: 20,
totalElements: 0,
totalPages: 0,
last: true,
};
}
const baseUrl = `${process.env.NEXT_PUBLIC_API_URL}/your-resource`;
const url = query ? `${baseUrl}?${query.toString()}` : baseUrl;
try {
const response = await fetchWithHmac(url, {
method: "GET",
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
const parsed = PagedResponseSchema.parse(data);
return parsed;
} catch (error) {
console.error("[YourResource] Fetch failed:", error);
return {
content: [],
currentPage: 0,
pageSize: 20,
totalElements: 0,
totalPages: 0,
last: true,
};
}
}
Guarded Pattern Checklist
⚠️ CRITICAL: Fetch Cache Warning
NEVER add next: { revalidate, tags } to fetchWithHmac calls in external wrappers!
This enables Next.js fetch cache, which stores every unique URL as a separate cache entry. With high-cardinality APIs (100+ places × categories × dates × pages × event slugs), this creates hundreds of thousands of cache entries.
Jan 20, 2026 Incident: Adding next: { revalidate } to external wrappers caused:
- 146,394 fetch cache entries per build (vs baseline 150)
- S3 objects: 400K → 1.46M (3.6x increase)
- DynamoDB writes: 16K → 280K/day (17x increase)
- See:
docs/incidents/2026-01-20-fetch-cache-explosion.md
Correct caching approach:
const response = await fetchWithHmac(url, {
next: { revalidate: 600, tags: ["events"] },
});
const response = await fetchWithHmac(url);
Common Patterns
Pagination
export async function fetchEventsExternal(
query?: URLSearchParams
): Promise<PagedResponseDTO<Event>> {
const parsed = PagedResponseSchema.parse(data);
return parsed;
}
Client-side infinite scroll:
const { data, size, setSize } = useSWRInfinite((pageIndex) => {
const params = buildEventsQuery({ place, page: pageIndex });
return getInternalApiUrl("/api/events", params);
}, fetcher);
const isReachedEnd = data?.[data.length - 1]?.last;
Search Term Mapping
IMPORTANT: Internal filter key searchTerm maps to API param term.
if (filters.searchTerm) {
params.set("term", filters.searchTerm);
}
Distance & Geolocation
UI filter uses distance + lat + lon.
API expects radius + lat + lon.
import { distanceToRadius } from "types/event";
if (filters.distance && filters.lat && filters.lon) {
params.set("radius", distanceToRadius(filters.distance).toString());
params.set("lat", filters.lat.toString());
params.set("lon", filters.lon.toString());
}
Never send default distance (50) - omit to reduce URL bloat.
Date Filtering
if (filters.byDate) {
params.set("byDate", filters.byDate);
} else if (filters.from || filters.to) {
if (filters.from) params.set("from", filters.from);
if (filters.to) params.set("to", filters.to);
}
Don't send both byDate and from/to unless intentional.
Security Best Practices
HMAC Signing
DO:
import { fetchWithHmac } from "@lib/api/fetch-wrapper";
const response = await fetchWithHmac(url, {
method: "GET",
next: { revalidate: 600 },
});
DON'T:
const response = await fetch(url);
import crypto from "crypto";
const signature = crypto.createHmac("sha256", secret).update(url).digest("hex");
Middleware HMAC Enforcement
Public endpoints (GET, no auth needed):
/api/events
/api/news
/api/categories
/api/places
/api/regions
/api/cities
Protected endpoints (POST/PUT/DELETE, HMAC required):
/api/visits (POST)
/api/events/* (POST/PUT/DELETE)
/api/auth/* (POST — login, register, forgot/reset password, email verification)
IMPORTANT: skipBodySigning: true is required on all POST/PUT/DELETE calls.
The backend HMAC verification ignores the request body — it only signs timestamp + pathAndQuery. Without this flag, mutation requests fail with 401 "Invalid HMAC".
const response = await fetchWithHmac(`${apiUrl}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
skipBodySigning: true,
});
const response = await fetchWithHmac(`${apiUrl}/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name }),
});
Implementation: proxy.ts allowlists public GET routes; all others require HMAC.
Fetch Best Practices
✅ ALWAYS Use Safe Fetch
DO:
import { fetchWithHmac } from "@lib/api/fetch-wrapper";
const response = await fetchWithHmac(url, {
});
import { safeFetch, fireAndForgetFetch } from "@utils/safe-fetch";
const response = await safeFetch(url, {
});
await fireAndForgetFetch(url, { method: "POST", body: data });
DON'T:
const response = await fetch(url);
const controller = new AbortController();
setTimeout(() => controller.abort(), 5000);
const response = await fetch(url, { signal: controller.signal });
ESLint warns on raw fetch() - always use wrappers.
Error Handling & Sentry
Sentry Tagging Conventions
Always include consistent tags for searchability:
import { captureException } from "@sentry/nextjs";
captureException(error, {
tags: {
section: "events-fetch",
type: "validation-failed",
layer: "external-wrapper",
},
extra: {
params,
responseStatus: response.status,
},
});
Standard tags:
| Tag | Values |
|---|
section | events-fetch, news-fetch, categories, webhooks |
type | validation-failed, fetch-failed, timeout, hmac-error |
layer | external-wrapper, internal-route, client-lib |
⚠️ Preserve Stack Traces
DO:
captureException(error, {
tags: { section: "events-fetch" },
extra: { params },
});
DON'T:
captureException(new Error(error.message), {
tags: { section: "events-fetch" },
});
captureException(new Error("Fetch failed"));
Validation Failure Pattern
When Zod validation fails, capture with context:
const result = PagedResponseSchema.safeParse(data);
if (!result.success) {
console.error("Validation failed:", result.error.format());
captureException(new Error("API response validation failed"), {
tags: {
section: "events-fetch",
type: "validation-failed",
},
extra: {
zodErrors: result.error.format(),
rawData: JSON.stringify(data).slice(0, 1000),
},
});
return safeFallback;
}
Error Message Sanitization
Use the helper to prevent leaking sensitive data:
import { getSanitizedErrorMessage } from "@utils/api-error-handler";
catch (error) {
const errorMessage = getSanitizedErrorMessage(error);
console.error("Fetch failed:", errorMessage);
}
Testing
Unit Test (External Wrapper)
import { fetchYourResourceExternal } from "@lib/api/your-resource-external";
describe("fetchYourResourceExternal", () => {
it("returns empty data when NEXT_PUBLIC_API_URL missing", async () => {
delete process.env.NEXT_PUBLIC_API_URL;
const result = await fetchYourResourceExternal();
expect(result.content).toEqual([]);
expect(result.last).toBe(true);
});
it("handles API errors gracefully", async () => {
global.fetch = vi.fn().mockRejectedValue(new Error("Network error"));
const result = await fetchYourResourceExternal();
expect(result.content).toEqual([]);
});
});
Integration Test (Internal Route)
import { GET } from "@app/api/your-resource/route";
import { NextRequest } from "next/server";
describe("GET /api/your-resource", () => {
it("returns cached response with correct headers", async () => {
const request = new NextRequest("http://localhost:3000/api/your-resource");
const response = await GET(request);
const data = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("Cache-Control")).toContain("s-maxage=600");
expect(data).toHaveProperty("content");
});
});
Common Mistakes
❌ Calling External API from Pages
export default async function EventsPage() {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/events`);
const events = await res.json();
return <EventList events={events} />;
}
import { fetchEvents } from "@lib/api/events";
export default async function EventsPage() {
const events = await fetchEvents({ place: "barcelona" });
return <EventList events={events} />;
}
❌ Duplicating Query Logic
const params = new URLSearchParams();
params.set("place", "barcelona");
params.set("term", searchTerm);
const params = buildEventsQuery({ place: "barcelona", searchTerm });
❌ Missing Env Guard
export async function fetchData() {
const url = `${process.env.NEXT_PUBLIC_API_URL}/data`;
const res = await fetch(url);
return res.json();
}
export async function fetchData() {
if (!process.env.NEXT_PUBLIC_API_URL) {
console.warn("[Data] API URL not set");
return { content: [] };
}
}
❌ Forgetting Cache Headers
return NextResponse.json(data);
return NextResponse.json(data, {
headers: {
"Cache-Control": "s-maxage=600, stale-while-revalidate=3600",
},
});
⚠️ CRITICAL: Build-Time vs Runtime Behavior
The Problem: Internal Routes Don't Exist During Build
During next build (static generation), internal API routes (/api/*) are not available because the Next.js server isn't running yet. This causes a chicken-and-egg problem:
- Build tries to generate static pages
- Pages call client libraries (
lib/api/events.ts, lib/api/places.ts, etc.)
- Client libraries call internal routes via
getInternalApiUrl("/api/...")
- Internal routes don't exist yet → returns HTML error page
- JSON parse fails:
"<!DOCTYPE "... is not valid JSON
Environment-Specific Behavior
| Environment | isBuildPhase | Behavior |
|---|
| Docker build | true (no running server) | Bypasses internal routes ✅ |
| Local dev | false | Uses internal routes (server running) ✅ |
The Solution: isBuildPhase Bypass Pattern
Every client library in lib/api/*.ts MUST check isBuildPhase and call external wrappers directly during build:
import { isBuildPhase } from "@utils/constants";
import { fetchYourResourceExternal } from "./your-resource-external";
export async function fetchYourResource(): Promise<YourDTO[]> {
const apiUrl = process.env.NEXT_PUBLIC_API_URL;
if (!apiUrl) return [];
if (isBuildPhase) {
try {
return await fetchYourResourceExternal();
} catch (error) {
console.error("fetchYourResource: external fetch failed during build", error);
return [];
}
}
try {
return await resourceCache(fetchFromInternalApi);
} catch (e) {
console.error("Error fetching resource:", e);
return [];
}
}
Files That MUST Have This Pattern
| File | Functions | Status |
|---|
lib/api/events.ts | fetchEvents, getCategorizedEvents | ✅ Has pattern |
lib/api/regions.ts | fetchRegionsWithCities, fetchRegionsOptions | ✅ Has pattern |
lib/api/cities.ts | fetchCities | ✅ Has pattern |
lib/api/places.ts | fetchPlaceBySlug, fetchPlaces | ✅ Has pattern |
lib/api/categories.ts | fetchCategories, fetchCategoryById | ✅ Has pattern |
lib/api/news.ts | Check if needed | Verify |
How to Verify
grep -l "isBuildPhase" lib/api/*.ts | grep -v external
Symptoms of Missing Pattern
- Build error:
SyntaxError: Unexpected token '<', "<!DOCTYPE "... is not valid JSON
- Build error:
Error fetching X by slug: HTTP 401 (if HMAC secret missing)
- Error only appears when Next.js cache is cleared or on fresh deploy
Resources
Templates
Examples
Reference Files
lib/api/*-external.ts - External wrappers
app/api/* - Internal routes
utils/api-helpers.ts - Query builders
utils/fetch-with-hmac.ts - HMAC fetch utility
utils/safe-fetch.ts - Safe fetch wrappers
FAQ
Q: When do I need a new API route?
A: When adding a new backend resource or endpoint variant. Example: /api/events/trending.
Q: Can I skip the internal route and call external wrapper directly?
A: No. Pages/components should never import *-external.ts. Always go through /api/*.
Q: What if I need to call a third-party API (not our backend)?
A: Create an internal route (/api/third-party) that proxies the call. Use safeFetch if no HMAC needed.
Q: How do I debug HMAC signature mismatches?
A: Check middleware logs (proxy.ts). Ensure HMAC_SECRET matches between client and server.
Q: Should I use fetch or axios?
A: Use fetchWithHmac (for internal API with HMAC) or safeFetch (for external APIs). Never raw fetch.
Last Updated: January 15, 2026
Maintainer: Development Team
Related Skills: filter-system-dev, type-system-governance