Netlify Integration Guide
What is Netlify?
Framework-agnostic primitives: Edge Functions, Functions, managed Postgres, Blobs, Image CDN, and a keyless AI Gateway.
Edge Functions run on Deno at the request CDN node; Functions run in a regional Node runtime (60s sync, 15 min background). Whatever framework you use compiles down to those two, so primitives land a layer below the framework. Netlify Database is managed Postgres that branches per deploy preview and applies migrations automatically. AI Gateway injects provider API keys and base URLs so `new Anthropic()` works with no key of your own, billed to Netlify credits. Cache control is unusually deep — `Netlify-CDN-Cache-Control` plus `Netlify-Vary` on query, country, language, or cookie — which is the real lever for 2G/3G markets.
Eight Netlify primitives
Framework-agnostic building blocks — your framework compiles down onto them.
███╗ ██╗███████╗████████╗██╗ ██╗███████╗██╗ ██╗
████╗ ██║██╔════╝╚══██╔══╝██║ ██║██╔════╝╚██╗ ██╔╝
██╔██╗ ██║█████╗ ██║ ██║ ██║█████╗ ╚████╔╝
██║╚██╗██║██╔══╝ ██║ ██║ ██║██╔══╝ ╚██╔╝
██║ ╚████║███████╗ ██║ ███████╗██║██║ ██║
╚═╝ ╚═══╝╚══════╝ ╚═╝ ╚══════╝╚═╝╚═╝ ╚═╝Netlify Integration Guide
Focus: Deploying sites, managing edge functions, and automating Netlify projects from Claude Code using the official Netlify MCP server and
netlify-cli.
Overview
Netlify is a platform for hosting web apps with built-in CI/CD, edge functions, forms, and identity. Its official MCP server (launched Feb 2025) lets Claude Code create projects, trigger deploys, manage environment variables, install extensions, and query build logs — all via natural language inside your coding session.
Here is the big picture of how a change reaches your users — once you see the flow, the commands below click into place:
Platform Primitives
Netlify's capabilities are exposed as framework-agnostic platform primitives rather than framework features. Whatever you build with — Next.js, Astro, Nuxt, Remix, TanStack Start, SvelteKit — the framework's server code is compiled into Netlify Functions and Edge Functions at build time, and every primitive below becomes available without framework-specific plumbing.
That is the whole design argument: you are not waiting for your framework to add support for image optimisation or cache control, because those live a layer below it.
| Primitive | What it is | Section |
|---|---|---|
| Functions | Regional Node handlers, web-standard Request → Response, 60s sync / 15 min background | Serverless Functions |
| Edge Functions | Deno at the CDN node — auth gates, redirects, geo, A/B | Edge Function Example |
| Netlify Database | Managed Postgres with per-preview branching and automatic migrations | Netlify Database |
| Blobs | Zero-config key-value / object store, callable from Functions and the CLI | — |
| Image CDN | On-demand resize + format negotiation via /.netlify/images | — |
| Caching | Netlify-CDN-Cache-Control, cache tags, SWR, durable cache | Edge Network |
| AI Gateway | Keyless access to OpenAI / Anthropic / Gemini / OpenRouter models | AI Gateway |
| Agent Runners | Coding agents run on Netlify, one DB branch + preview per run | Agent Runners |
| Forms | HTML form capture with no backend code | — |
| Identity | Built-in auth (GoTrue) — codeAmani uses Clerk instead | Security |
Everything above runs locally. netlify dev emulates the full set — Functions, Edge Functions, Blobs, the database, AI Gateway, redirects, and the Image CDN — and Vite projects get the same through @netlify/vite-plugin without invoking the CLI. For tests, @netlify/dev exposes that emulator as a library. This is the practical reason the local/production gap on Netlify is small: it is the same engine, not a mock.
Official Documentation
MCP Server Setup
Official Netlify MCP Server
The official Netlify MCP server ships as the @netlify/mcp npm package (repo:
netlify/netlify-mcp). The older netlify-mcp package name no longer exists — use
@netlify/mcp or the hosted remote endpoint.
# Local (stdio) — runs the package via npx
claude mcp add netlify -- npx -y @netlify/mcp
# Remote (hosted) — Netlify's managed endpoint, handles auth via OAuth in the client
npx -y add-mcp https://netlify-mcp.netlify.app/mcp.mcp.json Configuration
{
"mcpServers": {
"netlify": {
"command": "npx",
"args": ["-y", "@netlify/mcp"],
"env": {
"NETLIFY_AUTH_TOKEN": "${NETLIFY_AUTH_TOKEN}"
}
}
}
}Requirements: Node.js 22+, a Netlify account with a personal access token
(NETLIFY_AUTH_TOKEN). The remote endpoint (https://netlify-mcp.netlify.app/mcp)
authenticates over OAuth instead of a token and needs no local Node runtime.
Available MCP Tools
The current server is capability/service based (not one tool per action). Tools group into reader/updater services plus a coding-context helper:
| Tool | Description |
|---|---|
get-netlify-coding-context | Fetch Netlify's current best-practice context for a capability (serverless, edge-functions, blobs, image-cdn, forms, db) — call before writing Netlify code |
netlify-project-services-reader / …-updater | Read / create + configure projects (sites), env vars, and project settings |
netlify-deploy-services-reader / …-updater | Query deploy status + logs; trigger and manage deploys |
netlify-extension-services-reader / …-updater | Discover and install/configure Netlify extensions |
netlify-team-services-reader | Team, membership, and billing info |
netlify-user-services-reader | Authenticated user info |
The server also exposes documentation skills for each primitive: netlify-functions,
netlify-edge-functions, netlify-blobs, netlify-db, netlify-image-cdn,
netlify-forms, netlify-config, netlify-cli-and-deploy, netlify-caching, and
netlify-ai-gateway.
CLI Integration
Installation
npm install -g netlify-cliCurrent major is netlify-cli v27 (requires Node.js 22+). Verify with
netlify --version; upgrade with npm install -g netlify-cli@latest.
Authentication
# Interactive login (browser OAuth)
netlify login
# Token-based (for CI)
export NETLIFY_AUTH_TOKEN=...Key Commands
# Initialize / link a site
netlify init
netlify link
# Deploy (draft)
netlify deploy
# Deploy to production
netlify deploy --prod
# Open site dashboard
netlify open
# Run dev server (with Functions/Edge Functions)
netlify dev
# Invoke a serverless function locally
netlify functions:invoke my-function --payload '{"key":"value"}'
# Manage environment variables
netlify env:set MY_VAR "my-value"
netlify env:list
netlify env:unset MY_VAR
# View build and deploy logs
netlify status
netlify logs:deploy
# Pull environment to local file
netlify env:import .envEnvironment Variables
# Required for MCP and CLI
NETLIFY_AUTH_TOKEN=... # From app.netlify.com/user/applications
# Optional project-specific
NETLIFY_SITE_ID=... # From site settings or `netlify link`
# Variables set for your deployed site
DATABASE_URL=postgresql://... # only if you bring your own database
NEXT_PUBLIC_API_URL=https://api.example.comNETLIFY_DB_URL is injected for you. If the project uses Netlify Database,
Netlify sets this connection string automatically in builds, agent runners, functions, and edge
functions, resolved to the correct database branch for the current deploy. Never set it by hand,
never commit it, and never pin it in a secrets vault — a hard-coded value will point at the wrong
branch (or a rotated credential). Read it via Netlify.env.get("NETLIFY_DB_URL"), or better, call
getConnectionString() from @netlify/database.
Inside function code, read variables via the global Netlify.env — not
process.env. Netlify injects a web-standard Netlify object into both serverless
and edge functions:
const key = Netlify.env.get("DARAJA_CONSUMER_SECRET"); // server-side onlyStore all secrets (Stripe signing secret, Daraja consumer key/secret) as env vars —
never in code or in netlify.toml, which is committed.
Automation Workflows
Claude Code Hook: Lint Before Deploy
.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "if echo \"$CLAUDE_TOOL_INPUT\" | grep -q 'netlify deploy --prod'; then npm run lint && npm run test; fi"
}
]
}
]
}
}Slash Command: Deploy to Netlify
.claude/commands/netlify-deploy.md:
Deploy the current project to Netlify production.
Run the following steps:
1. Use Bash to run `npm run build` and confirm it succeeds
2. Use Bash to run `netlify deploy --prod --dir=dist` (adjust dir as needed)
3. Report the deploy URL and any warnings
4. If deploy fails, use Bash to run `netlify logs:deploy` and report errorsUsage: /project:netlify-deploy
GitHub Actions: Preview + Production Deploy
# .github/workflows/netlify.yml
name: Netlify Deploy
on:
pull_request:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
- run: npm ci && npm run build
- name: Deploy Preview
if: github.event_name == 'pull_request'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: |
npm install -g netlify-cli
deploy_url=$(netlify deploy --dir=dist --json | jq -r '.deploy_url')
echo "Preview: $deploy_url"
- name: Deploy Production
if: github.ref == 'refs/heads/main'
env:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
run: netlify deploy --prod --dir=distEdge Function Example
Edge functions are great for running logic close to the user before a request reaches your site — here is how the auth example below fits into a request:
netlify/edge-functions/auth.ts:
import type { Config, Context } from "@netlify/edge-functions";
export default async function handler(req: Request, context: Context) {
const token = req.headers.get("Authorization");
if (!token || !token.startsWith("Bearer ")) {
return new Response("Unauthorized", { status: 401 });
}
return context.next();
}
export const config: Config = {
path: "/api/*",
};Serverless Functions
Edge functions run light logic at the CDN edge; serverless functions are full Node.js handlers for heavier work — talking to a database, calling the Daraja STK Push API, signing tokens, or handling M-Pesa callbacks. They live in netlify/functions/ and use the same web-standard Request → Response signature as edge functions, but run in a regional Node runtime with the full npm ecosystem available.
Function Example
netlify/functions/stk-push.mts:
import type { Config, Context } from "@netlify/functions";
export default async (req: Request, context: Context) => {
if (req.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}
const { phone, amount } = await req.json();
// Heavy lifting belongs here: DB writes, Daraja token + STK Push, etc.
// const token = await getDarajaToken();
// const result = await initiateStkPush({ phone, amount, token });
return new Response(JSON.stringify({ ok: true, phone, amount }), {
status: 200,
headers: { "content-type": "application/json" },
});
};
export const config: Config = {
path: "/api/stk-push",
};Install the types once: npm install @netlify/functions. The .mts extension opts into ES modules; netlify/functions/stk-push.mts, netlify/functions/stk-push/stk-push.mts, and netlify/functions/stk-push/index.mts all define a function named stk-push.
How to Invoke
- With
config.path(recommended): the function answers at the clean route you set, e.g.https://your-site.netlify.app/api/stk-push. Named params likepath: "/order/:id"arrive oncontext.params. - Without
config: it falls back to the default routehttps://your-site.netlify.app/.netlify/functions/stk-push. - Locally:
netlify devserves functions onlocalhost:8888; or call one directly withnetlify functions:invoke stk-push --payload '{"phone":"254708374149","amount":1}'.
[functions] Config
The default directory is netlify/functions. esbuild is now the default bundler,
so this block is optional — set it only to change the directory or opt out:
[functions]
directory = "netlify/functions"
node_bundler = "esbuild"Timeouts & long-running work
Function limits are fixed and uniform across all plans (they are not configurable — the old 10s-free / 26s-Pro tiering is gone):
| Function type | Execution limit | How to opt in |
|---|---|---|
| Synchronous (default) | 60 seconds | default |
| Background | 15 minutes | -background suffix on the file/dir name; returns 202 immediately, response body ignored |
| Scheduled (cron) | 30 seconds | export const config = { schedule: "@hourly" } — runs on published deploys only, UTC cron |
Background and scheduled functions are the escape hatch for work that exceeds 60s (e.g. Daraja STK Push polling, batch AI inference): persist the result to Netlify Blobs or Postgres and read it back from a synchronous function.
Gotcha: Edge vs Serverless
Reach for the right runtime — they are not interchangeable:
Edge functions run on Deno at the CDN edge (no full Node API, no node_modules bundling), so M-Pesa/Daraja calls, raw Postgres drivers like pg, and Node-only SDKs belong in serverless functions. Use edge functions only for fast request manipulation (auth gates, redirects, geo, A/B) close to the user.
Exception — @netlify/database. getDatabase() picks a connector suited to its runtime, so it does work in edge functions where a raw pg client would not. A lightweight lookup at the edge (feature flag, tenant routing, session check) is therefore fair game; keep heavier transactional work, which needs db.pool, in serverless functions. See Netlify Database below.
Netlify Database
Netlify Database is a fully managed Postgres database built into the platform. Netlify provisions it, applies migrations, and branches it for you. It is built on Neon (serverless Postgres) under the hood, but the setup and management surface is entirely Netlify's — you never interact with Neon directly, and there is no separate Neon account to link.
The headline feature is database branching. Production deploys are the only deploys allowed to touch the production database; every deploy preview gets its own isolated branch, seeded with a copy of production data taken when the preview is first created. Schema changes and data mutations made in a preview never reach production, and a bad branch can simply be reset.
This is the same safety model Deploy Previews gave your code, extended to your data — which is also why each Agent Runner run gets its own branch: an agent can rewrite the schema and delete rows without any risk to the live site.
When to reach for it
| Situation | Pick |
|---|---|
| App already hosted on Netlify, wants Postgres with zero setup | Netlify Database |
| Need per-preview isolated data for PRs or agent runs | Netlify Database (branching is the differentiator) |
| Need row-level security tied to an auth provider, storage, realtime | Supabase (see supabase/) |
| Need Postgres decoupled from the host, or used from Vercel | Neon directly (see neon/) |
| Key/value or unstructured blobs, not relational data | Netlify Blobs |
Prerequisites
- Node.js 20.12.2 or later
- Netlify CLI 26.0.0 or later (
npm install -g netlify-cli; check withnetlify --version) - A credit-based plan — Netlify Database is not offered on other plans
Setup
The fastest path on an existing project is the interactive initialiser:
netlify database init # interactive
netlify database init --yes # non-interactive (CI / AI agents)It installs @netlify/database, lets you pick a query style (raw SQL or Drizzle ORM), scaffolds a starter migration, optionally seeds sample data, and verifies the database is reachable.
To wire it up manually instead:
npm install @netlify/databaseThen write your first migration under netlify/database/migrations/, add a function that queries it, and deploy — Netlify provisions the database and applies the migration as part of the deploy lifecycle.
Gotcha — provisioning is triggered by the package. If
@netlify/databaseis not installed in the project, Netlify will not auto-provision a database. Either install it, or create the database by hand in the UI under Data & Storage → Database.
Querying with @netlify/database
getDatabase() returns a client already configured for wherever the code is running — it selects a different underlying connector for builds and long-running servers than it does for Functions and Edge Functions. That is why it works in an edge function, where a raw pg client would not.
import { getDatabase } from "@netlify/database";
const db = getDatabase();
// Tagged template — interpolated values are ALWAYS parameterized (no SQL injection)
const userId = 42;
const users = await db.sql`SELECT * FROM users WHERE id = ${userId}`;
// Type the returned rows
interface User { id: number; name: string; email: string }
const typed = await db.sql<User>`SELECT id, name, email FROM users`;
// Stream large result sets instead of buffering them
for await (const chunk of db.sql`SELECT * FROM orders`.chunked(100)) {
console.log(`Processing ${chunk.length} rows`);
}The sql tagged template is based on Waddler. A SQLTemplate is thenable and also exposes execute(), stream(), chunked(size), and toSQL() (returns the SQL string + params without running it — handy for debugging).
Helpers on db.sql:
| Helper | Purpose |
|---|---|
sql.identifier(v) | Safely quote a dynamic table/column name |
sql.values(rows) | Build a bulk VALUES list from a 2-D array |
sql.default | The SQL DEFAULT keyword, for inserts |
sql.raw(v) | Inject an unparameterized fragment — bypasses injection protection |
sql.unsafe(q, params?) | Run a raw query string with positional $1 params |
Security:
sql.raw()is the one escape hatch that will happily interpolate attacker-controlled input into SQL. Never pass user input to it — usesql.identifier()for dynamic table/column names and ordinary${}interpolation for values.
Transactions
db.sql does not pin a connection, so BEGIN/COMMIT must run on a single client from the pool. db.pool is a standard pg.Pool:
import { getDatabase } from "@netlify/database";
const db = getDatabase();
const client = await db.pool.connect();
try {
await client.query("BEGIN");
await client.query("INSERT INTO users (name, email) VALUES ($1, $2)", ["Ada", "ada@example.com"]);
await client.query("INSERT INTO posts (author_id, title) VALUES ($1, $2)", [1, "First post"]);
await client.query("COMMIT");
} catch (e) {
await client.query("ROLLBACK");
throw e;
} finally {
client.release(); // always release, or the pool leaks connections
}Migrations
Migrations live in netlify/database/migrations/ — either as flat .sql files or as one subdirectory per migration containing migration.sql:
netlify/database/migrations/
├── 20260301143000_create_users.sql
├── 20260318091500_add_posts.sql
└── 20260425103000_create_comments.sqlNames must match <number>_<slug>: digits setting the order, then a slug of lowercase letters, digits, hyphens, and underscores. Migrations are sorted lexicographically, which is why timestamp prefixes are safer than hand-numbered ones once more than one person (or agent) is adding them.
Netlify applies them automatically:
- Production deploys — applied immediately before the deploy is published; a failure blocks publication
- Deploy previews — applied on every new deploy, just before it goes live; a failure fails the deploy
Because they run immediately before the new code goes live, the window where old code meets a new schema is small — but it is not zero. Write backwards-compatible migrations. For breaking changes use the expand-and-contract pattern: add the new column alongside the old and write to both, backfill, then drop the old one in a later migration.
Gotcha — the directory is magic. Anything in
netlify/database/migrations/is auto-applied. If you bring your own migration tool (Prisma Migrate, Atlas, raw scripts), point it at a different directory or Netlify will run those files too.
CLI reference
Every command takes --json for structured output, which is what makes this surface agent-friendly:
netlify database status # enabled? package installed? applied + pending migrations
netlify database status --branch my-feat # target a remote branch instead of local
netlify database status --show-credentials # include the full connection string
netlify database connect # interactive SQL REPL
netlify database connect --query "SELECT * FROM users"
netlify database connect --json # print connection details as JSON
netlify database migrations new --description "add users table" --scheme timestamp
netlify database migrations apply # apply pending migrations locally
netlify database migrations apply --to 0003 # apply up to a specific migration
netlify database migrations pull # overwrite local files from a remote branch
netlify database migrations reset # delete local, unapplied migration files
netlify database reset # wipe the LOCAL dev database onlynetlify database reset and netlify database migrations reset only ever touch the local development database — they cannot damage production or a preview branch.
Local development
netlify dev starts a real Postgres-compatible database on your machine and shuts it down with the dev server — there is no Docker container or local Postgres install to manage:
netlify dev
netlify database migrations apply # the local DB does NOT auto-apply migrationsVite projects can get the same emulated environment without netlify dev by adding @netlify/vite-plugin. Both use the same engine, so data and migrations are interchangeable between them.
Connect any Postgres tool (psql, TablePlus, DataGrip) while it runs:
psql "$(netlify database connect --json | jq -r .connection_string)"For integration tests, @netlify/database-dev exposes the emulator as a library (new NetlifyDB() → start() / applyMigrations(dir) / stop()), defaulting to in-memory on a random port. Use @netlify/dev when a test needs the whole Netlify runtime, not just the database.
Differences from production worth knowing: it is a single local process (not a load-testing target), branching is a deploy-time concept so locally there is exactly one database, and auto-scale/sleep settings do not apply.
Bringing your own driver or ORM
The connection string is available two ways — getConnectionString() from @netlify/database, or the NETLIFY_DB_URL environment variable, which is injected into builds, agent runners, functions, and edge functions.
import { getConnectionString } from "@netlify/database";
import pg from "pg";
const pool = new pg.Pool({ connectionString: getConnectionString() });
const { rows } = await pool.query("SELECT * FROM users");Drizzle ORM has a native adapter. Install from the beta tag (these become 1.0 shortly and carry a better migration format), and point Drizzle Kit's output at Netlify's migrations directory or the automatic runner will never see them:
npm install @netlify/database drizzle-orm@beta
npm install -D drizzle-kit@beta// drizzle.config.ts
export default defineConfig({
dialect: "postgresql",
schema: "./db/schema.ts",
out: "netlify/database/migrations", // ← not the default "drizzle"
});// db/index.ts — the connection is configured automatically
import { drizzle } from "drizzle-orm/netlify-db";
import * as schema from "./schema";
export const db = drizzle({ schema });Scaling, sleep, and cost
Compute is metered in database compute units — one unit = 25% of a vCPU + 1 GB RAM. Auto-scale sets a min/max the database moves between; sleep on inactivity (default: after 5 minutes idle) pauses it so an idle database stops consuming credits.
| Meter | Cost |
|---|---|
| Database compute | 10 credits per compute unit |
| Database bandwidth (data out) | 20 credits per GB |
| Storage | Free until 1 July 2026, then billed at rates announced in advance |
Selected plan limits (the full table is in the billing docs):
| Limit | Free | Personal | Pro | Enterprise |
|---|---|---|---|---|
| Databases per account | 3 | 5 | 50 | 500 |
| Branches per database | 20 | 100 | 300 | 450 |
| Max compute units | 1 | 4 | 16 | 32 |
| Max sleep-on-inactivity | 5 min | 5 min | Always on | Always on |
| Storage per database | 5 GB | 100 GB | 100 GB | No limit |
| Bandwidth per billing period | 5 GB | 100 GB | 100 GB | No limit |
REST API
All endpoints are site-scoped, rooted at https://api.netlify.com/api/v1, and authenticate via OAuth 2:
| Endpoint | Purpose |
|---|---|
POST / GET /sites/{site_id}/database | Create or read the database (returns connection_string) |
POST /sites/{site_id}/database/branch | Create a branch for a deploy_id |
GET / DELETE /sites/{site_id}/database/branch/{deploy_id} | Read or delete a deploy's branch |
POST /sites/{site_id}/database/snapshot | Point-in-time snapshot (defaults to production) |
GET /sites/{site_id}/database/snapshots | List snapshots |
POST /sites/{site_id}/database/snapshot/{id}/restore | Restore a snapshot to a branch |
codeAmani notes
- Never store cardholder data. Netlify Database is not PCI-DSS certified. Card numbers, PANs, and other regulated cardholder data must not be stored, processed, or transmitted through it. This is a non-issue if you keep the standard codeAmani pattern — Stripe holds the card, we persist only the Stripe customer / payment-intent IDs.
- Not HIPAA-eligible by default. Protected Health Information must not go in unless the account has been explicitly configured for HIPAA with Netlify. Anything health-adjacent (e.g. DoseVault) needs that conversation before this is chosen as the store.
- There is no RLS layer. Unlike Supabase, this is plain Postgres with no policy engine wired to an auth provider — a leaked connection string is full database access. Authorize every query inside the function against the Clerk session; do not expect the database to do it for you.
NETLIFY_DB_URLis server-side only. It belongs in functions, edge functions, and builds. Never inline it into a client bundle, and never prefix it withNEXT_PUBLIC_/VITE_.- M-Pesa idempotency fits the branching model well. Store
CheckoutRequestIDon STK Push with aUNIQUEconstraint and dedupe callbacks withINSERT ... ON CONFLICT DO NOTHING; preview branches then let you replay Daraja sandbox callbacks against real-shaped data without touching production ledgers. - Mind sleep-on-inactivity for Kenya-targeted apps. On Free/Personal the database sleeps after 5 minutes idle, so the first request after a lull pays a wake-up cost on top of an already slow 2G/3G round trip. Pro and Enterprise can set it always-on; below that, warm it or set expectations in the UI.
- The hosting default is unchanged. Vercel remains the codeAmani default. Netlify Database is a reason to stay on Netlify when a project already lives there and wants per-preview data isolation — it is not on its own a reason to migrate.
Edge Network
Every deploy is published to Netlify's global edge network — a CDN with atomic deploys: a deploy either goes live completely or not at all, and publishing automatically invalidates the cache for changed content. There is no manual purge step in the normal workflow.
Cache-control headers
Netlify honours three cache-control fields, most specific wins:
| Header | Applies to | Precedence |
|---|---|---|
Netlify-CDN-Cache-Control | Netlify's CDN only | Highest |
CDN-Cache-Control | Any CDN that supports it | Middle |
Cache-Control | Browsers and CDNs | Lowest (fallback) |
Use Netlify-CDN-Cache-Control to cache aggressively at the edge while keeping browsers on a short leash:
return new Response(body, {
headers: {
"Netlify-CDN-Cache-Control": "public, durable, s-maxage=86400, stale-while-revalidate=604800",
"Cache-Control": "public, max-age=0, must-revalidate", // browser always revalidates
},
});Two directives are worth knowing:
stale-while-revalidate=<s>— keep serving the stale object for this many seconds while it is refreshed in the background. This is what turns a slow origin into a fast page.durable— promotes the object into Netlify's durable cache, so it survives beyond a single edge node and is shared across the network. (Not yet supported on edge function responses.)
Cache key variation with Netlify-Vary
Netlify-Vary controls what makes a request a different cache entry — finer-grained than the standard Vary header:
Netlify-Vary: query=page|per_page, country=ke|us, language=en|sw, cookie=is_logged_in| Instruction | Varies on |
|---|---|
query / query=a|b | All query params, or only the listed ones |
header=X|Y | Named request headers |
language=en|sw | Accept-Language, honouring quality values |
country=ke|us | Geo-IP country |
cookie=key | Named cookies |
Rules that bite:
- A given URL should return the same
Netlify-Varyon every response — the first one cached wins and later ones are ignored. - High-cardinality headers (
Accept-Language,Cookie, …) are rejected as rawheader=instructions; use the dedicatedlanguage=/cookie=instructions with an explicit list. - Query instructions are case-sensitive in name, but parameter order does not matter.
- If you put Cloudflare in front of Netlify, use standard
Varyfor it —Netlify-Varyis Netlify-specific. - Cache key variation is not compatible with basic auth, and On-demand Builders ignore it entirely.
High-Performance Edge (Enterprise)
An Enterprise-only upgrade to the standard network: 70+ global points of presence with dynamic PoP adjustment, a dedicated Site Reliability Engineer, 24×7×365 incident response, and proactive DDoS protection. Netlify quotes up to 50% faster than their standard network (and up to 300% faster than a traditional monolith). Relevant only if a client is on Enterprise — the standard edge is what codeAmani projects actually run on.
codeAmani notes
- This is the lever for Kenya-targeted projects. On 2G/3G the round trip dominates, so a
durable+stale-while-revalidatepolicy on API responses and rendered pages does more for perceived speed than any bundle-size work. Cache first, optimise JavaScript second. Netlify-Vary: countrypairs well with KES/locale switching — one cached page per country rather than a personalised uncached render.- Do not cache authenticated responses at the edge. Anything behind a Clerk session needs
Cache-Control: private, no-store, and cache key variation is unavailable under basic auth anyway.
AI Gateway
AI Gateway lets project code call OpenAI, Anthropic, Google Gemini, OpenRouter, and TypeSafe AI models with no API keys of your own. Netlify injects both the API key and a provider-specific base URL into every compute context, proxies the call, and bills the token usage to your Netlify credits.
How it works
When a Function or Edge Function initialises, Netlify sets these — but never overrides a value you have already set at project or team level:
| Provider | Injected variables |
|---|---|
| OpenAI | OPENAI_API_KEY, OPENAI_BASE_URL |
| Anthropic | ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL |
| Google Gemini | GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL |
| OpenRouter | OPENROUTER_API_KEY, OPENROUTER_BASE_URL |
| TypeSafe AI | TYPESAFE_API_KEY, TYPESAFE_BASE_URL |
NETLIFY_AI_GATEWAY_KEY and NETLIFY_AI_GATEWAY_URL are always injected and never collide with the above — use them when you deliberately mix your own keys with Netlify's, or want to be explicit about which path a call takes.
Because the official SDKs read these variables by default, the code is just the SDK with no configuration:
// netlify/functions/summarise.ts
import Anthropic from "@anthropic-ai/sdk";
import type { Config, Context } from "@netlify/functions";
// No apiKey argument — ANTHROPIC_API_KEY and ANTHROPIC_BASE_URL are injected.
const anthropic = new Anthropic();
export default async (req: Request, context: Context) => {
const { text } = await req.json();
const message = await anthropic.messages.create({
model: "claude-sonnet-4-5-20250929",
max_tokens: 1024,
messages: [{ role: "user", content: `Summarise in two sentences:\n\n${text}` }],
});
return Response.json({ summary: message.content });
};
export const config: Config = { path: "/api/summarise" };Framework server code (Next.js, Astro, Nuxt, TanStack Start, …) is packaged into Functions at build time, so the same variables are available there with no extra setup. The OpenRouter SDK needs v1.2.43 or later.
Requirements and gotchas
- Credit-based plan, and the project must have had at least one production deploy — AI Gateway does not activate on a project that has never shipped.
- Works locally through
netlify devor@netlify/vite-plugin. - Netlify does not store prompts or model outputs; AI features can be disabled team-wide.
Cost and rate limits
Token usage is converted to USD at published provider rates, then to credits: $1 USD of model usage = 180 credits. Rate limits are per minute, per team, across all projects:
| Plan | Credits / minute |
|---|---|
| Free | 90 |
| Personal | 450 |
| Pro | 1,800 |
| Enterprise | 9,000 |
Current limitations: context window capped at 200k tokens; Anthropic prompt caching is limited to the default 5-minute ephemeral cache; Gemini explicit context caching is unsupported; request headers are not passed through (so header-gated experimental features are unavailable); no batch inference; no OpenAI priority processing.
codeAmani notes
- This does not replace the AI routing policy — it changes the wiring. Claude stays primary for reasoning and code generation, OpenAI for structured output; AI Gateway is simply a way to reach them without provisioning
ANTHROPIC_API_KEYper project. For a Netlify-hosted prototype it removes a whole class of secret management. - Prefer direct provider keys for anything heavy or long-running. The 200k context cap, missing header passthrough, and absent batch inference make the Gateway a poor fit for large-document pipelines; wire those to Anthropic directly via Hazina-managed keys.
- Always add rate-limiting rules to any function that calls the Gateway. Without them, one abusive visitor drains team-wide credits — and the limit is shared across every project on the team.
- Set your own key to opt out per project. Because Netlify never overrides a variable you set, providing
ANTHROPIC_API_KEYyourself silently routes around the Gateway. That is the migration path when a project outgrows it.
Agent Runners
Agent Runners run a coding agent inside Netlify's infrastructure, prompted from the dashboard (or a phone) rather than a local terminal. The agent gets the project's repo, environment variables, build settings, and deploy pipeline, and ships its work to a deploy preview for review.
Supported agents: Claude Code, OpenAI Codex, Google Gemini, and OpenCode. Each run's model and reasoning effort are configurable per agent, and those settings are a personal preference — they do not apply team-wide. OpenCode is served via OpenRouter and Netlify routes only to providers with a Zero Data Retention policy; the other three run on their vendors' own models.
Available on credit-based plans (Free, Personal, Pro); Enterprise teams go through their account manager.
The pairing with Netlify Database is the point: every run gets its own database branch, so an agent can add tables, write migrations, and mutate data with no path to production until a human publishes.
Good fits: well-defined backlog items, broken links and redirects, copy and content updates from non-engineers, landing/404/maintenance pages, scaffolding a platform primitive, and on-the-go fixes. Poor fits: anything needing deep local iteration, a debugger, or judgement about architecture.
codeAmani notes
- Complementary to Claude Code locally, not a replacement. Real feature work stays in the terminal where tests, git history, and the superpowers workflow live. Agent Runners are for the small, well-specified changes that are not worth a local checkout — and for letting non-engineers file a change safely.
- Review the preview before publishing, every time. The isolation guarantee covers production data, not correctness; an approved run ships real code.
- Runs consume credits from the same team pool as AI Gateway and builds — watch the two together.
Observability
Analytics & metrics → Observability gives near-real-time visibility into production: requests, bandwidth, runtime behaviour, Functions, and Edge Functions. It answers "what is actually happening on the site right now", and it replaces Function Metrics on credit-based plans.
Retention depends on plan:
| Plan | Time window |
|---|---|
| Free / Personal | Past 24 hours |
| Pro | Past 7 days |
| Enterprise | Past 30 days |
Quick actions apply pre-built filter sets across three axes:
| Axis | Answers |
|---|---|
| Traffic | Top URLs, top 404s, top URLs with errors, client types, top AI-crawler searches, browser-only traffic |
| Bandwidth | Bandwidth by URL, by client type, by content type |
| Compute | Most-invoked functions, slowest URLs, which clients drive function usage |
The rest of the monitoring surface sits alongside it:
| Tool | Use |
|---|---|
| Log drains | Stream deploy/function/traffic logs to an external sink (Datadog, S3, …) |
| Logs | Per-deploy and per-function logs in the dashboard |
| Real User Monitoring | Field performance data from actual visitors |
| Lighthouse | Scores generated as part of the build |
| Notifications | Deploy/build events to Slack, email, or webhooks |
| Split testing | Branch-based A/B at the edge |
Observability does not show credit usage. Spend lives under billing — monitor usage for credit-based plans. Two different questions, two different screens.
codeAmani notes
- Sentry stays the error-monitoring system of record. Observability is platform-level (requests, cache status, bandwidth, invocation counts); Sentry is application-level (stack traces, releases, user context). Use Observability to find that
/api/mpesa/callbackis erroring or slow, then Sentry to find why. - Cache-status and bandwidth filters are the feedback loop for the Edge Network work above — if a Kenya-targeted page is slow, check cache misses here before touching code.
- On Free/Personal the 24-hour window means an incident review the next morning has already lost the data. Wire log drains for anything that matters.
Security
Netlify's security surface splits into three areas: access to your sites, access to Netlify itself, and platform-level protections.
Secure access to sites
| Control | What it does |
|---|---|
| Password protection | Single shared password on a site or deploy preview |
| Project visibility | Public / private project listings |
| Firewall traffic rules | Allow or block by IP address or geography |
| Rate limiting rules | Per-visitor request caps (use these in front of AI Gateway functions) |
| Web Application Firewall | Managed rule sets against common attack traffic |
| Role-based access control | Per-role permissions on site access |
| Basic auth via custom headers | Credentials enforced at the edge |
Secure access to Netlify
SAML SSO through an identity provider, SCIM directory sync for provisioning, enforced 2FA, role-based access control, and the Secrets Controller — an enhanced policy for the most sensitive environment variables that blocks them from being exposed in builds and adds secret scanning. Netlify also scans deploys for leaked secrets and can fail the build when it finds one.
Platform protections
Proactive DDoS monitoring with automatic detection, rate limiting, and client blocking; global load balancing; AES-256 (or stronger) encryption at rest; TLS 1.2+ in transit; Content Security Policy support; log drains; and Private Connectivity for Enterprise. Enterprise teams also get a Security Scorecard that grades the team's posture.
Compliance
SOC 2 Type 2 and ISO 27001 reports, PCI DSS, GDPR and CCPA — current details live at the Netlify trust center.
Careful — platform PCI DSS does not extend to Netlify Database. The hosting platform carries PCI DSS, but Database Services are explicitly not PCI-DSS certified and are not HIPAA-eligible by default. Hosting a payment page on Netlify is fine; writing cardholder data into Netlify Database is not. See the Netlify Database compliance notes.
codeAmani notes
- Clerk remains the auth provider. Netlify Identity exists and still works, but codeAmani standardises on Clerk (
clerk/) so auth is portable across Vercel and Netlify. Do not introduce Identity into a new build without a specific reason. - Hazina remains the secret store. Secrets Controller is a good second line of defence inside Netlify — enable it for production keys — but the source of truth stays Hazina, and values still never pass through chat.
- Secret scanning is a safety net, not the gate. The repo-side gate is
gitleaksbefore push; Netlify's scan catches what slips into a build. - Rate limiting is a cost control, not just a security control — it is the single most effective guard on AI Gateway and Functions spend.
- Still verify webhook signatures yourself (Stripe signing secret, Svix for Clerk, M-Pesa callback validation). None of the above authenticates a webhook payload for you.
Common Use Cases
| Use Case | Approach |
|---|---|
| Deploy on merge | GitHub Actions + netlify deploy --prod |
| Preview URLs for PRs | netlify deploy (no --prod) in PR workflow |
| Edge function auth | netlify/edge-functions/ directory |
| Form submissions | Netlify Forms + MCP netlify-project-services-reader |
| Environment management | MCP netlify-project-services-updater / CLI env:set |
| Extension management | MCP netlify-extension-services-updater |
| Relational data | Netlify Database — netlify database init, query via @netlify/database |
| Safe schema changes | Migrations in netlify/database/migrations/, auto-applied per deploy |
| Isolated data per PR | Deploy previews get their own DB branch automatically — no setup |
| Local DB for dev/tests | netlify dev (bundled Postgres) or @netlify/database-dev in tests |
| LLM calls without keys | AI Gateway — official SDK with no apiKey, billed to Netlify credits |
| Agent-driven small fixes | Agent Runners from the dashboard; each run gets its own DB branch + preview |
| Fast pages on 2G/3G | Netlify-CDN-Cache-Control: durable, stale-while-revalidate=... |
| Per-country cached pages | Netlify-Vary: country=ke|us instead of uncached personalisation |
| Find slow / erroring routes | Observability → Quick actions → Compute or Traffic insights |
| Cap per-visitor AI spend | Rate limiting rules on the function that calls AI Gateway |
Troubleshooting
| Issue | Fix |
|---|---|
netlify: command not found | npm install -g netlify-cli |
| Not linked to a site | Run netlify link in project root |
| Build fails in CI | Check NETLIFY_AUTH_TOKEN and NETLIFY_SITE_ID secrets |
| Edge function not triggering | Verify config.path matches the route |
| Function hits the 60s timeout | Timeout is fixed (not configurable) — move long work to a background (-background) or scheduled function |
| No database provisioned on deploy | @netlify/database must be installed, or create it manually under Data & Storage → Database |
netlify database command not found | Needs Netlify CLI ≥ 26.0.0 and Node ≥ 20.12.2 — npm install -g netlify-cli |
| Drizzle migrations never run | Set out: "netlify/database/migrations" in drizzle.config.ts (default drizzle dir is ignored) |
| Local queries hit an empty schema | The local DB does not auto-migrate — run netlify database migrations apply |
| First request after idle is slow | Sleep-on-inactivity (5 min default); Pro/Enterprise can set the database always-on |
AI Gateway vars missing / OPENAI_BASE_URL undefined | Needs a credit-based plan and at least one production deploy |
| AI calls bypass the Gateway unexpectedly | You set that provider's key yourself — Netlify never overrides it; unset it or use NETLIFY_AI_GATEWAY_* |
| AI Gateway 429s | Team-wide per-minute credit limit (Free 90 / Personal 450 / Pro 1,800); add rate-limiting rules |
Netlify-Vary seems ignored | First response cached for that URL wins; also unsupported under basic auth and On-demand Builders |
| Stale content after deploy | Atomic deploys auto-invalidate — check for a manual durable policy or an upstream CDN (Cloudflare) |
| Observability data has vanished | Retention is 24h on Free/Personal, 7d Pro, 30d Enterprise — use log drains to keep history |
netlify.toml example:
[build]
command = "npm run build"
publish = "dist"
[functions]
node_bundler = "esbuild"
[[edge_functions]]
path = "/api/*"
function = "auth"