← Plugin catalog
Developer Tools

Netlify

Netlify v1.6.0

Publisher description

From the marketplace listing

Gives the agent factual reference for Netlify platform primitives — functions, edge functions, blobs, database, identity, image CDN, forms, config, caching, frameworks, AI Gateway, and deployment — plus the official hosted Netlify MCP server, which lets the agent create and manage Netlify projects, deploys, and environment variables after you sign in to Netlify.

Language: English · Automatically detected from descriptions.

Publisher keywords

Search terms declared by the publisher.

Show all 10 keywords

Matches for “skill”

Exact text from the indicated source. A mention alone does not establish support for your task.

Publisher description

Netlify platform skills — functions, edge functions, blobs, database, identity, image CDN, forms, config, CLI, frameworks, caching, AI gateway, and deployment — plus the official Netlify MCP server.

Changes

Netlify

Oct 7, 2026 · 14 saved observations

Capabilities & instructions

Instruction wording changed from “Guide for using Netlify Image CDN for image optimization and transformation. Use when serving optimized images, creating responsive image markup, setting up user-uploaded image pipelines, or configuring image transformations. Covers the ...” to “Transforms images on demand via Netlify Image CDN's /.netlify/images endpoint with query parameters for resizing/cropping/format/quality. Use when adding image optimization or responsive images, converting formats (WebP/AVIF/PNG), genera...”. 109 additional added or edited lines are in the evidence.

Skill evidence →
Capabilities & instructions

Instruction wording changed from “Use when the task involves authentication, user signups, logins, password recovery, OAuth providers, role-based access control, or protecting routes and functions. Always use `@netlify/identity`. Never use `netlify-identity-widget` or `g...” to “Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task invol...”. 151 additional added or edited lines are in the evidence.

Skill evidence →
Pricing references

Instruction wording changed from “Guide for writing Netlify serverless functions. Use when creating API endpoints, background processing, scheduled tasks, or any server-side logic using Netlify Functions. Covers modern syntax (default export + Config), TypeScript, path r...” to “Write, configure, and deploy Netlify serverless functions in TypeScript, JavaScript, or Go. Use this when adding an API endpoint or backend route, adding a contact form handler, wiring auth or Identity signup/login hooks, building stream...”. 236 additional added or edited lines are in the evidence.

Skill evidence →
10 more changes that day

Instruction wording changed from “Guide for deploying web frameworks on Netlify. Use when setting up a framework project (Vite/React, Astro, TanStack Start, Next.js, Nuxt, SvelteKit, Remix) for Netlify deployment, configuring adapters or plugins, or troubleshooting frame...” to “Deploy and configure web frameworks on Netlify — build settings and SSR/edge adapters plus local platform emulation and env vars. Use when setting up or fixing a framework deploy (Next.js / Astro / Nuxt / SvelteKit / Remix / React Router...”. 192 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Guide for using Netlify Forms for HTML form handling. Use when adding contact forms, feedback forms, file upload forms, or any form that should be collected by Netlify. Covers the data-netlify attribute, spam filtering, AJAX submissions,...” to “Serverless form handling on Netlify-hosted sites — detects HTML forms at deploy time, stores submissions, filters spam, and sends notifications. Use when adding a contact form, lead-capture form, file-upload form, or newsletter signup to...”. 119 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Guide for writing Netlify Edge Functions. Use when building middleware, geolocation-based logic, request/response manipulation, authentication checks, A/B testing, or any low-latency edge compute. Covers Deno runtime, context.next() midd...” to “Write and configure Netlify Edge Functions — TypeScript/JavaScript handlers running in a Deno runtime at the network edge. Use when adding auth middleware or auth redirects, geolocation or localization logic, A/B testing or personalizati...”. 204 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Deploy projects to Netlify with the Netlify CLI. Use when the user wants to link a repo, validate deploy settings, run a deploy, or choose between preview and production flows.” to “Create, configure, and manage Netlify deploys from code — reach for this when setting up Git continuous deployment, running netlify deploy or netlify deploy --prod from the CLI, writing netlify.toml deploy contexts, adding a Deploy to Ne...”. 131 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Reference for netlify.toml configuration. Use when configuring build settings, redirects, rewrites, headers, deploy contexts, environment variables, or any site-level configuration. Covers the complete netlify.toml syntax including redir...” to “Configure Netlify builds and routing via netlify.toml, _redirects, and _headers. Use when setting a build command or publish directory, adding redirects or rewrites or proxies, adding an SPA fallback rewrite, setting custom response head...”. 188 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Guide for controlling caching on Netlify's CDN. Use when configuring cache headers, setting up stale-while-revalidate, implementing on-demand cache purge, or understanding Netlify's CDN caching behavior. Covers Cache-Control, Netlify-CDN...” to “Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache...”. 195 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Guide for using Netlify Blobs object storage. Use when storing files, images, documents, or simple key-value data without a full database. Covers getStore(), CRUD operations, metadata, listing, deploy-scoped vs site-scoped stores, and lo...” to “Store and retrieve unstructured objects, files, and cache-like state on Netlify with the @netlify/blobs module. Use when persisting user file uploads (images/documents), caching computed output from functions or Background Functions, ser...”. 158 additional added or edited lines are in the evidence.

Skill evidence →

Instruction wording changed from “Guide for using Netlify AI Gateway to access AI models. Use when adding AI capabilities or selecting/changing AI models. Must be read before choosing a model. Covers supported providers (OpenAI, Anthropic, Google), SDK setup, environment...” to “Use Netlify AI Gateway to call OpenAI, Anthropic Claude, Google Gemini, TypeSafe (Jev), or OpenRouter-hosted models (xAI/DeepSeek/Meta/Mistral/Qwen) from Netlify Functions or Edge Functions without managing provider accounts or API keys....”. 189 additional added or edited lines are in the evidence.

Skill evidence →

Product description changed from “Build and deploy on Netlify” to “Netlify platform skills — functions, edge functions, blobs, database, identity, image CDN, forms, config, CLI, frameworks, caching, AI gateway, and deployment — plus the official Netlify MCP server.”.

Metadata evidence →Listing evidence →

Package contents changed in 99 files: .app.json, .codex-plugin/plugin.json, CHANGELOG.md, …. Open the file diff to inspect the edits.

Files evidence →

Files & skills

File archives

Plugin package44 files · 108 KBBrowse files →
Skill instructions
netlify-access-control12.2 KB

View saved version →

---
name: netlify-access-control
description: 'Picks the right Netlify site-protection layer and disambiguates the three unrelated "auth" concepts users conflate — app-user login (Netlify Identity), site-load gating (Password Protection / project visibility), and dashboard SAML SSO. Use it when asked to password-protect a site or Deploy Preview, make a project private/public, restrict a site to your team, require SSO to view a site, set up company-wide app SSO, or invite users to a private project. Also use it for SSO-session symptoms on protected sites: "logged out mid-session", 401s after about an hour, or token expiry/refresh questions. Not for wiring auth code — route app-login setup to netlify-identity.'
---

# Netlify access control — pick the protection layer

This skill routes you to the correct protection layer. It does not teach each one. **These settings have no public API, CLI command, or MCP tool.** Never curl `api.netlify.com` or read local auth tokens to inspect or change them — give the user the dashboard path and checklist. On failure, report what you tried and stop.

## First: disambiguate "auth" — three unrelated layers

Users constantly conflate these. Identify which one is meant before recommending anything.

1. **Netlify Identity** — "who is this user *inside my app*." Issues `nf_jwt`. → route to the **netlify-identity** skill; not covered here.
2. **Password Protection / project visibility** — "can this request load the site at all." Covered here.
3. **Team/Org SAML SSO** — "can you log in to the Netlify *dashboard*." Gates dashboard access; also underlies team-login site protection.

Sessions are separate. The same provider (e.g. Google) can appear twice unrelated — Identity OAuth for app users vs. SAML IdP for team members.

**Double-login footgun:** a Password-Protection/team-login perimeter session and an Identity app session have **no bridge** — no shared cookie, no header forwarding, no JWT exchange. Don't try to wire them together. For the combined layered pattern and its tradeoffs, see `references/two-layer-pattern.md`.

**Want company-wide app SSO with a single sign-in (no double login)?** Recommend the **Auth0 extension** federating to the corporate IdP *before* the two-layer stack.

## Decision guide (this skill's job)

- Restrict entire site to your team, invite by email → **Private project** (Credit-based) or **Team login protection** (Password Protection).
- Share with anyone holding one shared password → **Basic password protection** (Pro) or **Password** visibility (Pro, Credit-based).
- Keep production public, protect previews only → scope **Previews only** / **Non-production deploys only**.
- Require SSO to *view a site* → Organization/Team SSO with **Only SSO allowed (strict)**, then Password Protection with **Team login protection**.
- Protect specific pages/sections with multiple passwords → **Basic authentication with custom HTTP headers** (formerly Selective password protection): https://docs.netlify.com/manage/security/secure-access-to-sites/basic-authentication-with-custom-http-headers/
- Authenticate your own end users → **Netlify Identity** / **OAuth provider tokens** / **Role-based access control with JWT** → route to netlify-identity.
- Block malicious/automated traffic or AI crawlers → **Advanced Web Security** (WAF / Firewall Traffic Rules / rate limiting) or **User Agent Blocker** extension: https://docs.netlify.com/build/build-with-ai/block-ai-crawlers/

## Key distinction: Private vs Password

- **Private** already requires Netlify credentials — no shared password. Invite by email; recommended for team-only access.
- **Password** = one universal shared password anyone can use (including managing team members, who must also enter it). No SSO.
- **Team login protection** = same mechanism as Private: a visitor must log in as a member of your Netlify team, and **Reviewers** you invite can get in too (unlimited and not counted toward the member count on legacy plans; Pro or higher on Credit-based plans). **Git Contributors cannot log in** — invite them as Reviewers rather than upgrading them to Developer.

## SSO-session symptom: 401s after ~1 hour

If a user reports being "logged out mid-session" or 401s on an SSO-protected site: **SSO auth tokens expire after 1 hour**, after which requests return `401`. Sites with SSO protection return the header **`Netlify-Site-Protection-Expires-In`** — seconds until the request's token expires. Refresh proactively:

```js
// Client-side. Checks the Netlify SSO protection header and reloads before expiry.
const res = await fetch(window.location.href, { method: "HEAD" });
const secondsLeft = Number(res.headers.get("Netlify-Site-Protection-Expires-In"));
// Tokens last 1 hour (3600s). Reload a bit early to avoid a 401.
if (!Number.isNaN(secondsLeft) && secondsLeft < 60) {
  window.location.reload();
}
```

## UI paths (the only path — no API)

**Credit-based plans (Free, Personal, Pro)** — project-level "Password Protection" is replaced by **Project visibility**:
- Per project: Project configuration > General > Visitor access > **Project visibility** — `https://app.netlify.com/projects/{site_name}/configuration/general/#project-visibility`. Edit visibility → (Customize if a team default exists) → **Public** / **Password** (Pro only) / **Private** → set **Preview access** (Production and previews / Previews only) → Save.
- Team default: Team settings > General > Visitor access > **Default project visibility** — `https://app.netlify.com/teams/{team_name}/settings/general#default-project-visibility`. Options: Private for new projects / Private for all projects / Public for new projects.
- No per-team default *password* here; set a password per project.

**Enterprise / Open Source / legacy (non-Credit-based)** — use **Password Protection** UI:
- Per site: Project configuration > General > Visitor access > **Password Protection** — `https://app.netlify.com/projects/{site_name}/configuration/general#visitor-access`. Configure → Basic or Team login → scope (All deploys / Non-production deploys only) → Save.
- Team default: Team settings > Access & security > Visitor access > **Default Password Protection settings** — `https://app.netlify.com/teams/{team_name}/settings/access#default-site-protection-settings`. Applies to all sites without their own settings.

**Legacy → Credit-based mapping:** No protection→Public · Basic protection→Password · Team protection→Private · All deploys→Production and previews · Non-production deploys only→Previews only.

## Constraints & footguns

- **Site-specific Password Protection overrides team defaults.**
- **Who can change these settings:** project visibility — Organization Owners (on certain Enterprise plans), Team Owners, and Developers with access to that project; **Internal Builders cannot publish to production, so they cannot make a project public**. Password Protection — a Developer changes it per site, a Team Owner sets the team default.
- **Advanced Web Security runs before password/login prompts** — a blocked IP hits an error page before ever seeing the prompt. Internal order: Firewall Traffic Rules → WAF → Rate limiting.
- **Third-party webhooks (Slack, Stripe, etc.) cannot reach a private project** — receiving webhooks requires the project to be **public**.
- **Make public** requires at least one successful **production deploy**.
- **Protecting only non-production deploys** with Password Protection is **Enterprise only**.
- **Plan gating:** Basic password protection for the whole site → all Pro plans; all Password Protection options → Enterprise. Project visibility (public/private, private-by-default) → Credit-based Free/Personal/Pro only; password-protected visibility → Pro only. On Free/Personal a private project is visible only to the Team Owner (single-seat); Pro allows unlimited members.
- **Team default changes by creation date:** teams created on/after **July 28, 2026** default to **Private for new projects**; earlier teams default to **Public**.
- **Renamed:** "site-wide password protection" (old name of a Password Protection option); "Selective password protection" → Basic authentication with custom HTTP headers.

Reference: https://docs.netlify.com/manage/security/secure-access-to-sites/overview/ · https://docs.netlify.com/manage/security/secure-access-to-sites/password-protection/ · https://docs.netlify.com/manage/security/secure-access-to-sites/project-visibility/

<!-- Advanced Web Security (WAF, Firewall Traffic Rules, rate limiting) specifics — limits, config keys, plan gating — not in source; referenced by URL only. -->
<!-- Exact per-tier matrix of basic vs team-login options across plans is only partially stated in sources. -->

<!-- system: agent-context/access-control/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (access-control)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. This is a routing/disambiguation skill: keep it narrow — its job is
   picking the right protection layer, not teaching each one.
2. The combined Password-Protection + Identity pattern lives in this skill's
   `references/two-layer-pattern.md`.
3. "Auth" on Netlify is three unrelated layers users constantly conflate:
   Netlify Identity ("who is this user inside my app" — issues `nf_jwt`),
   Password Protection / project visibility ("can this request load the site
   at all"), and Team/Org SAML SSO ("can you log in to the Netlify
   dashboard"). Sessions are separate; the same provider (Google) can appear
   in two unrelated places — Identity OAuth for app users, SAML IdP for team
   members. Disambiguate before recommending anything.
4. The double login is real: a Password-Protection/team-login perimeter
   session and an Identity app session have no bridge — no shared cookie, no
   header forwarding, no JWT exchange. Don't burn iterations wiring them
   together; tradeoffs live in `references/two-layer-pattern.md`.
5. These settings have no public API, CLI command, or MCP tool. Never curl
   `api.netlify.com` or read local auth tokens to inspect or change them —
   hand the user the dashboard path and checklist; on failure, report what
   you tried and stop.
6. Identity setup, auth code, and OAuth providers for app users belong to the
   netlify-identity skill — route there; this skill only picks the layer.
7. For company-wide app-level SSO with a single sign-in (no double login),
   the Auth0 extension — federating to the corporate IdP — is the
   recommendation before the two-layer stack.
8. The description's triggers must include the SSO-session symptoms users
   actually report — "logged out mid-session", 401s on an SSO-protected
   site, token expiry/refresh — not only setup phrasing. The
   `Netlify-Site-Protection-Expires-In` guidance is unreachable if the
   skill never triggers on the symptom.
9. Team login protection excludes Git Contributors, and the answer to that is
   **Reviewers**, never a Developer seat. Reviewers can open team-login-
   protected deploys: unlimited and not counted toward the member count on
   legacy plans, Pro or higher on Credit-based plans. Every answer about Git
   Contributor access must name the Reviewer path — recommending an upgrade to
   Developer sells a paid seat per person to solve something the product
   already solves for free. Stating the exclusion without the remedy is the
   failure mode this rule exists to prevent; it has happened. Never frame the
   permitted roles as a closed list ("only X, Y and Z get in") — even with the
   Reviewer path added after it, a closed list reads as Reviewers being shut
   out, and agents repeat it. Naming roles is optional; the question is
   usually only about Git Contributors.
10. Say who can change these settings, not only how to change them. Project
   visibility: Organization Owners (on certain Enterprise plans), Team Owners,
   and Developers with access to that project — and Internal Builders cannot
   make a project public, because they cannot publish to production. Password
   Protection: a Developer per site, a Team Owner for the team default. A
   checklist handed to someone without the role is a dead end.

Referenced files: 1

netlify-agent-runner10.7 KB

View saved version →

---
name: netlify-agent-runner
description: Run AI agent tasks remotely on Netlify using Claude, Codex, or Gemini. Use when the user wants to run an AI agent on their site, get a second opinion from another model, or delegate development tasks to run remotely against their repo.
---

# Netlify Agent Runner

Run AI coding agents (Claude, Codex, Gemini) remotely on Netlify infrastructure to automate development tasks on your site.

## Prerequisites

- The site must be **linked to a Netlify project** (via `netlify link` or `netlify init`).
- **Or skip linking entirely:** pass `--project <name>` (a project ID or name) directly to `netlify agents:create` to target any Netlify site without linking first.
- The Netlify CLI must be installed and authenticated
- Agent runs **consume plan credits**. If the account has no available credits — or the agent/AI usage limit has been reached — `netlify agents:create` is **blocked** and the run won't start. That's an account/plan-state issue to surface to the user, not something to work around.

## Use only documented CLI surfaces

Interact with agent tasks only through the documented `netlify agents:*` commands (plus `netlify --help` and the public CLI reference). Do **not** go around the CLI:

- **Do not curl `https://api.netlify.com/...`** to fetch, create, or stop a task — the endpoint shapes are not part of the public contract.
- **Do not run `netlify api <method>`** as a recovery hatch when a documented command fails.
- **Do not read auth tokens** out of `~/Library/Preferences/netlify/config.json` (or anywhere on disk) to authenticate side-channel calls.

If a documented command fails, report the exact error and context to the user and stop — don't invent an undocumented way to reach the task.

## How Agent Tasks Run

Read this before creating a task — agent tasks behave differently from running an agent locally, and the differences are easy to miss.

- **Remote, not local.** Tasks run on Netlify infrastructure, not on your machine. They operate on the site's **connected repository**, not your local working tree. The remote agent only sees what has been pushed to the remote — it cannot see uncommitted or unpushed changes.
- **Branch-based.** By default a task runs against the production branch (`main` or `master`). To choose a different *base* branch for the agent to start from, use `-b <branch>` and make sure that branch has been **pushed to the remote first**, or the agent will be working from code that doesn't exist remotely. `-b` sets the base (starting) branch — not where the results are written (see the next bullet).
- **Output lands on a new branch — not in place.** The agent does **not** commit its changes onto the base branch you selected. It pushes its work to a **new branch** with its own **Deploy Preview**, so your existing branch (or `main`) is never overwritten. Review the task's results on that new branch / Deploy Preview — don't expect the base branch to change directly.
- **Asynchronous.** `netlify agents:create` returns as soon as the task is queued — it does **not** block until the work is finished. When the command returns, the task is still running remotely.
- **No webhooks or callbacks.** Nothing notifies you when a task changes state or completes. To find out what's happening, you have to **poll** with `netlify agents:show <task-id>` or `netlify agents:list`.
- **Statuses are terminal or not.** A task moves through `new` → `running` → one of `done`, `error`, or `cancelled`. Keep polling until the status is one of those last three before you act on the results.

### Typical workflow

1. **Create** a task: `netlify agents:create "<prompt>" -a <agent>`. Note the task ID it returns (use `--json` to capture it reliably).
2. **Poll** for status: `netlify agents:show <task-id>`. Repeat periodically — there is no completion notification — until the status is `done`, `error`, or `cancelled`.
3. **Review** the results once the task reaches `done` (or inspect the failure on `error`).

## Creating Agent Tasks

```bash
# Run a prompt with the default agent
netlify agents:create "Add a contact form"

# Choose a specific agent: claude, codex, or gemini
netlify agents:create --prompt "Add dark mode" --agent claude
netlify agents:create -p "Update the README" -a codex
netlify agents:create -p "Write unit tests" -a gemini

# Target a specific branch
netlify agents:create -p "Fix the login bug" -a claude -b feature-branch

# Specify a project by name (if not in a linked directory)
netlify agents:create "Add tests" --project my-site-name

# Output result as JSON
netlify agents:create "Add a footer" --json
```

### Options

| Flag | Description |
|------|-------------|
| `-a, --agent <agent>` | Agent type: `claude`, `codex`, or `gemini` |
| `-p, --prompt <prompt>` | The prompt for the agent to execute |
| `-b, --branch <branch>` | Git branch to work on |
| `-m, --model <model>` | Model to use for the agent |
| `--project <project>` | Project ID or name |
| `--json` | Output result as JSON |

## Managing Agent Tasks

All `netlify agents:*` commands are **project-scoped** — they operate on a single project (the one your directory is linked to, or the one named with `--project <name>`), not on your whole team. `netlify agents:list` shows the tasks for that one project only; there is no team-wide command that lists tasks across all your sites. To see a different site's tasks, run from its linked directory or pass `--project <name>` for it.

### List tasks

```bash
# List all tasks for the current site
netlify agents:list

# Filter by status
netlify agents:list --status running
netlify agents:list --status done
netlify agents:list --status error

# Output as JSON
netlify agents:list --json
```

Status values: `new`, `running`, `done`, `error`, `cancelled`.

### Show task details

```bash
netlify agents:show <task-id>
netlify agents:show <task-id> --json
```

### Stop a running task

```bash
netlify agents:stop <task-id>
```

## Use Cases

Some of the many things you can do with Agent Runners:

| Category | Example prompt |
|----------|---------------|
| Prototyping / internal tools | "Build an internal dashboard for our HR team" |
| Code reviews | "Audit the code with fresh eyes and identify areas for improvement" |
| Security audits | "Do a deep security audit of our codebase to identify any potential issues" |
| Feature suggestions | "Based on our current codebase & docs, what should we build next?" |
| Performance improvements | "Scan our codebase for performance bottlenecks and suggest improvements" |
| Telemetry & analytics | "What analytics things are we not tracking but probably should" |
| SEO audit | "Audit our site for SEO issues — missing meta tags, broken links, slow pages, missing alt text" |
| Copy improvements | "Rewrite our landing page copy to be more compelling and conversion-focused" |
| Accessibility | "Run an accessibility audit and fix all WCAG 2.1 AA violations" |
| Mobile responsiveness | "Improve the mobile responsiveness — audit every page on small viewports" |
| End-to-end tests | "Add end-to-end tests for our critical user flows using Playwright" |
| Unit tests | "Generate unit tests for our untested utility functions" |
| Documentation | "Generate a README and contributing guide based on our codebase" |
| Error handling | "Add proper error boundaries, logging, and user-friendly error states throughout the app" |
| UX polish | "Add loading states, skeleton screens, & transitions to improve perceived performance" |
| Form hardening | "Add form validation, rate limiting, and spam protection to our contact form" |
| Edge Functions | "Add an edge function for A/B testing on our landing page" |

## Using as an Agent

If you are an AI agent, you can use `netlify agents:create` to delegate work to an agent running remotely on Netlify — for example, to get a second opinion from a different model.

**IMPORTANT — ask for permission first, as a distinct confirmation step.** Agent tasks run on Netlify infrastructure and cost the user credits, so a real approval gate matters. Get explicit permission before running any `netlify agents:create` command — and treat that as its own turn, separate from the user's original request. A directive-sounding prompt ("start a task…", "use the claude agent and pin it to Opus") is **not** itself the approval: it tells you what they want, but the billable command still waits for a yes.

Make the permission request a concrete proposal, not a menu:

- **The exact command**, filled in — e.g. `netlify agents:create -p "<the real prompt>" -a codex` — not a `<placeholder>` and not a pick-one list of agents.
- **One agent, already chosen** — commit to a single `-a` value and say why you picked it ("codex for a second opinion on the auth logic"), rather than offering claude/codex/gemini as interchangeable options.
- **Why**, plus **what happens after "yes"**: the run is asynchronous — `agents:create` returns as soon as the task is queued, there's no callback, and you'll poll `netlify agents:show <task-id>` for the outcome.
- **Even if a prerequisite is missing** (not authenticated, not linked to a site, not a git repo yet), still show the exact command and chosen agent you'll run *once it's resolved* — surface the blocker **and** the concrete proposal, rather than collapsing to only describing the blocker.

Never run these commands without the user's approval.

Before delegating, understand what you're handing off (see [How Agent Tasks Run](#how-agent-tasks-run) above):

- **It runs remotely against the pushed branch — not your local work.** The remote agent only sees code that has been committed and pushed. Do **not** delegate work that depends on your local, in-progress changes; the remote agent can't see them and will work from stale code. If a task needs your current changes, commit and push them first (or finish the work yourself).
- **It's asynchronous — delegating does not block you.** The task runs remotely while you keep working. But because there are no callbacks, you have to poll (`netlify agents:show <task-id>`) to learn the outcome. Don't assume the task is done just because you delegated it — check the status before relying on or describing its results.
- **It's a separate, self-contained task — not a continuation of your session.** The remote agent starts fresh from the repo and the prompt you give it. It has none of your conversation context, so write a complete, standalone prompt.

Useful for:

- **Cross-validation** — get a second opinion on your implementation from a different model
- **Edge case discovery** — another model may catch issues you missed
- **Alternative approaches** — see how a different model would solve the same problem
- **Parallel work** — kick off an independent task remotely while you continue on other work, then poll for its result
netlify-ai-gateway13.7 KB

View saved version →

---
name: netlify-ai-gateway
description: Use Netlify AI Gateway to call OpenAI, Anthropic Claude, Google Gemini, TypeSafe (Jev), or OpenRouter-hosted models (xAI/DeepSeek/Meta/Mistral/Qwen) from Netlify Functions or Edge Functions without managing provider accounts or API keys. Reach for this when adding an AI feature to a Netlify app — a chatbot, text summarizer, image generator, joke/content generator, form-submission routing or analysis, or any LLM call — or when wiring the OpenAI/Anthropic/Gemini/TypeSafe/OpenRouter SDK into a Netlify Function, choosing which env vars to use, streaming long generations, or debugging why gateway calls fail at build time or return 401.
---

# Netlify AI Gateway

Call AI models from Netlify compute using the provider's official SDK. The gateway injects provider credentials automatically — instantiate the SDK with no args and it works.

**Use the provider SDK with injected env credentials.** Do not hand-roll a raw `fetch()` against the gateway URL, and do not wire calls to `NETLIFY_AI_GATEWAY_KEY` / `NETLIFY_AI_GATEWAY_URL` as your default path — those are for third-party/unsupported libraries only (see below).

## Footguns (read first)

- **Not browser-callable.** Gateway calls belong in Functions or Edge Functions — never in client-side code. The browser has no injected credentials.
- **Runtime-only credentials.** Never call the gateway from build scripts, prerender/SSG, or build plugins — those get no credentials and fail. Do AI work at request time; cache to Netlify Blobs if output must look precomputed.
- **60-second sync timeout.** A gateway call in a synchronous function is bound by the 60s function timeout. Stream long generations (SDK streaming + `ReadableStream`), or use a background function that persists output for the client to fetch. Never leave a slow generation unstreamed.
- **Requires one production deploy.** The gateway does not activate until a project has at least one production deploy. Even for local dev, run `netlify deploy --prod` once first.
- **Don't hardcode model lists.** Available models change. Check the live providers endpoint (`https://api.netlify.com/api/v1/ai-gateway/providers/detailed`) rather than baking in a static list.
- **OpenRouter SDK needs 1.2.43+.** Earlier versions ignore `OPENROUTER_BASE_URL`, call openrouter.ai directly, and fail with `401 Missing Authentication header`.

## Where code goes

Write normal Function/handler code — there is no AI-specific file type. A function at `netlify/functions/joke.js` exporting `config = { path: "/api/joke" }` is served at `/api/joke` under both `netlify dev` and production.

## Provider SDKs (instantiate with no args)

The gateway injects each provider's own env vars, so the official SDK works with zero config.

Anthropic Claude:
```js
import Anthropic from '@anthropic-ai/sdk';
const anthropic = new Anthropic(); // uses ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL

const message = await anthropic.messages.create({
  model: 'claude-sonnet-4-5-20250929',
  max_tokens: 1024,
  messages: [{ role: 'user', content: 'Hello!' }]
});
```

OpenAI:
```js
import OpenAI from 'openai';
const openai = new OpenAI(); // uses OPENAI_API_KEY, OPENAI_BASE_URL

const completion = await openai.chat.completions.create({
  model: 'gpt-5',
  messages: [{ role: 'user', content: 'Hello!' }]
});
```

Google Gemini:
```js
import { GoogleGenAI } from '@google/genai';
const genAI = new GoogleGenAI({}); // uses GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL

const result = await genAI.models.generateContent({
  model: 'gemini-2.5-pro',
  contents: 'Hello!'
});
```

TypeSafe (Jev) — structured decisions (e.g. routing/classifying form submissions):
```ts
import type { Config, Context } from '@netlify/functions';
import { choice, TypeSafeClient } from '@typesafe-ai/sdk';

export default async (req: Request, context: Context) => {
  const body = await req.json().catch(() => undefined);
  if (body === undefined)
    return Response.json({ error: 'Request body must be valid JSON.' }, { status: 400 });

  const client = new TypeSafeClient(); // uses TYPESAFE_API_KEY, TYPESAFE_BASE_URL
  const { answers } = await client.systemOne({
    state: body,
    questions: {
      team: choice('Route this contact form submission', {
        sales: null,
        support: null,
        spam: null,
      }),
    },
  });

  return Response.json({ team: answers.team.choice, requestId: context.requestId });
};

export const config: Config = { path: '/api/route', method: 'POST' };
```
`systemOne` defaults to the `jev-latest` model. Each question is a `choice(prompt, options)` mapping named options to `null`; the result is at `answers.<question>.choice`. POST a JSON body (e.g. `{"message":"Can someone help us upgrade to 200 seats?"}`) with `Content-Type: application/json`.

OpenRouter (SDK 1.2.43+ required — see footguns):
```js
import { OpenRouter } from '@openrouter/sdk';
const openRouter = new OpenRouter(); // uses OPENROUTER_API_KEY, OPENROUTER_BASE_URL

const result = await openRouter.chat.send({
  chatRequest: {
    model: 'x-ai/grok-4.5',
    messages: [{ role: 'user', content: 'Hello!' }]
  }
});
```

Models available through OpenRouter can be called with either the OpenRouter SDK or the OpenAI SDK using OpenRouter model-ID notation (e.g. `deepseek/deepseek-v4-flash-0731`) — just pass the ID as the `model`.

Model IDs above (`gpt-5`, `claude-sonnet-4-5-20250929`, `gemini-2.5-pro`, `x-ai/grok-4.5`, etc.) are examples that change — check the live providers endpoint.

## Env vars — which to use

**Default:** supported provider SDKs consume their injected provider-specific vars automatically. Instantiate the SDK with no args as shown above (`new OpenAI()`, `new Anthropic()`, `new GoogleGenAI({})`, `new TypeSafeClient()`, `new OpenRouter()`) and the corresponding pair is read for you:

- 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: `TYPESAFE_API_KEY`, `TYPESAFE_BASE_URL`

**Explicit-config path:** `NETLIFY_AI_GATEWAY_KEY` and `NETLIFY_AI_GATEWAY_URL` are always injected and never collide with user-set provider vars. Use this pair only when a third-party or unsupported library needs explicit key/base-URL configuration — pass them as constructor arguments. It is not the default; supported SDKs should use their provider-specific vars above.

**Precedence:** Netlify never overrides a key or base URL you set at project or team level. If you set your own provider key, the gateway defers to it. For Gemini specifically, injection is skipped if `GOOGLE_API_KEY` or `GOOGLE_VERTEX_BASE_URL` is set (Vertex/Google-API-key setups win).

To stop all injection, disable AI Features: https://docs.netlify.com/build/build-with-ai/manage-ai-for-your-team/manage-ai-features/#disable-ai-features

## Full example (Vite + React + Function)

Detect gateway availability by checking for an injected var, then call the SDK.

Install the client first: `npm install openai`. Then create `netlify/functions/joke.js`:
```js
import process from "process";
import OpenAI from "openai";

export default async () => {
  if (!process.env.OPENAI_BASE_URL)
    return Response.json({ error: "AI Gateway not active — deploy to prod once on a credit-based plan" });

  try {
    const client = new OpenAI();
    const res = await client.responses.create({
      model: "gpt-5-mini",
      input: [{ role: "user", content: "Give me a short dad joke about coffee" }],
      reasoning: { effort: "minimal" },
    });
    return Response.json({
      joke: res.output_text?.trim() || "Out of jokes",
      model: res.model,
      tokens: { input: res.usage.input_tokens, output: res.usage.output_tokens },
    });
  } catch (e) {
    return Response.json({ error: `${e}` }, { status: 500 });
  }
};

export const config = { path: "/api/joke" };
```

`src/App.jsx` fetches `/api/joke`:
```jsx
import { useState } from "react";

export default function App() {
  const [joke, setJoke] = useState();
  const [loading, setLoading] = useState(false);

  const getJoke = async () => {
    setLoading(true);
    try {
      const res = await fetch("/api/joke");
      setJoke(res.ok ? await res.json() : { error: res.status });
    } finally {
      setLoading(false);
    }
  };

  return (
    <>
      <button onClick={getJoke} disabled={loading}>
        {loading ? "Thinking..." : "Get joke"}
      </button>
      <pre>{JSON.stringify(joke, null, 2)}</pre>
    </>
  );
}
```

## Local development

Two options — both need at least one prior production deploy:

1. **Netlify CLI:** `netlify dev` gives full gateway support.
2. **Vite plugin:** access the gateway locally without `netlify dev`. Add `@netlify/vite-plugin` and run your native dev command (`npm run dev`):
```js
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import netlify from "@netlify/vite-plugin";

export default defineConfig({ plugins: [react(), netlify()] })
```

Setup flow:
```shell
npm install -g netlify-cli@latest
netlify login
npm create vite@latest dad-jokes -- --template react --no-interactive
cd dad-jokes && npm install
netlify init
netlify deploy --prod --open   # required: activates the gateway
```

## Billing, limits, constraints

- **Plans:** Credit-based plans only (Free, Personal, Pro). Enterprise: contact your Account Manager. Legacy plans must switch first. Enabled by default unless you disabled AI Features or set your own provider keys.
- **Cost:** tokens → USD (provider-published rates) → credits. **$1 USD = 180 credits.**
- **Rate limits** (per minute, per team, across all projects): Free 90, Personal 450, Pro 1,800, Enterprise 9,000 credits.
- **Context window:** input limited to 200k tokens.
- **Prompt caching:** Anthropic — only the default 5-min ephemeral cache; OpenAI — per-account `prompt_cache_key` set for you; Gemini — explicit context caching unsupported.
- **No pass-through headers** (can't enable header-gated experimental features), **no batch inference**, **no OpenAI priority processing**.
- **OpenRouter ZDR only:** Netlify routes only to providers with a Zero Data Retention policy. A model listed in the OpenRouter directory but with no ZDR-guaranteeing host is not served. Browse ZDR-eligible models: https://openrouter.ai/models?zdr=true
- **Privacy:** The gateway does not store prompts or model outputs.

**Cost controls:** Set up rate-limiting rules on AI-calling Functions/Edge Functions to prevent visitor abuse and runaway cost: https://docs.netlify.com/manage/security/secure-access-to-sites/rate-limiting/ — and configure auto-recharge or credit packs: https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/configure-auto-recharge/ · https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/buy-credit-packs/

Monitor usage: https://docs.netlify.com/manage/accounts-and-billing/billing/billing-for-credit-based-plans/monitor-usage-for-credit-based-plans

## References

- Overview: https://docs.netlify.com/build/ai-gateway/overview.md
- Quickstart: https://docs.netlify.com/build/ai-gateway/quickstart-for-ai-gateway.md
- Examples: https://docs.netlify.com/build/ai-gateway/examples.md — including the AI SEO Image Generator (Gemini image generation): https://github.com/netlify/examples/tree/main/examples/ai-seo-image-generator and a TanStack Start chat app: https://github.com/netlify-templates/tanstack-template

<!-- Gap: exact directly-served model names (Anthropic/OpenAI/Gemini/TypeSafe) are not statically enumerable — rendered at build time from the live providers endpoint. -->

<!-- system: agent-context/ai-gateway/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (ai-gateway)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Use the provider SDK with the injected env credentials — don't hand-roll
   a raw `fetch()` against the gateway, even though raw REST is a supported
   surface. The body must not present raw REST or the
   `NETLIFY_AI_GATEWAY_KEY` / `NETLIFY_AI_GATEWAY_URL` pair as a
   recommended path — but it MUST still document the pair as facts: always
   injected, never collide with user-set provider vars, and the right choice
   when a third-party or unsupported library needs explicit configuration.
   Demote the recommendation; keep the knowledge.
2. The gateway is not browser-callable: calls belong in functions or edge
   functions, never client-side code.
3. Model availability changes: don't hardcode model lists; check the live
   providers endpoint.
4. Gateway credentials are runtime-only: never call the gateway from build
   scripts, prerender/SSG, or build plugins — those calls get no credentials
   and fail. Do AI work at request time and cache the result (e.g. to
   Netlify Blobs) if it must look precomputed.
5. Gateway calls in a synchronous function are bound by the 60-second
   timeout: stream long generations (SDK streaming + `ReadableStream`), or
   use a background function that persists output for the client to fetch —
   never leave a slow generation unstreamed and assume it finishes.
6. When asked which env vars to use — even asked explicitly for the gateway
   pair — open with the default before answering the literal question:
   supported provider SDKs consume their injected provider-specific vars
   (`OPENAI_API_KEY`/`OPENAI_BASE_URL`, etc.) using exactly the per-provider
   instantiation the body shows — restate the body's setup, don't invent
   constructor details here. Then give `NETLIFY_AI_GATEWAY_KEY` /
   `NETLIFY_AI_GATEWAY_URL` as the explicit-config path for third-party
   or unsupported libraries. Answering with the gateway pair alone presents
   hand-wiring as the default, which it is not.
netlify-blobs12.1 KB

View saved version →

---
name: netlify-blobs
description: Store and retrieve unstructured objects, files, and cache-like state on Netlify with the @netlify/blobs module. Use when persisting user file uploads (images/documents), caching computed output from functions or Background Functions, serving downloadable assets, storing JSON blobs keyed by ID, or seeding deploy-specific data. Reach for this for key/value or object storage from Functions, Edge Functions, or Build Plugins — not for per-user, transactional, or relational data (use Netlify DB for that). Triggers include "save an uploaded file", "cache API results", "store generated site map", "key/value store for a function", or "file uploads without a database".
---

# Netlify Blobs

Modern syntax — import from `@netlify/blobs` and open a store, then call methods on the handle:

```ts
import { getStore, getDeployStore, listStores } from "@netlify/blobs";
import type { Context } from "@netlify/functions"; // or "@netlify/edge-functions"
```

In Functions, Edge Functions, and Build Plugins, `siteID`, `deployID`, `token` (and `region` for `getDeployStore`) are injected automatically. Install with `npm install @netlify/blobs`.

**Not a database.** For dynamic, per-user, transactional, or relational data, use Netlify DB. Blobs is for objects, files, and cache-like state, optimized for frequent reads and infrequent writes.

**Store scope is a footgun — read this first.** `getStore` opens a **site-wide store shared across ALL deploy contexts**: code on a deploy preview reads, overwrites, and deletes production data. Never run destructive tests or seed throwaway data from a preview against a site-wide store. Use `getDeployStore()` or a context-specific store name for isolation.

## Choosing a store type

- `getStore(name)` — site-wide, shared across all deploys. Data persists across deploys; previews see production data.
- `getDeployStore(name)` — deploy-specific, scoped to one deploy. Kept in sync on rollback, cleaned up on deploy deletion. Use for isolation and for anything a failed deploy must not corrupt.
- **Build plugins can READ from any of the site's stores, but WRITE only to deploy-specific stores** (`getDeployStore`). File-based uploads also write only to deploy-specific stores.

## Core writes and reads

```ts
const uploads = getStore("file-uploads");

// set: value is ArrayBuffer | Blob | string
await uploads.set(key, file, { metadata: { country: "Spain" } });

// setJSON: any JSON-serializable value
await uploads.setJSON(key, { hello: "world" });

// get: returns value or null. type: text (default) | json | arrayBuffer | blob | stream
const entry = await uploads.get(key);            // string
const obj = await uploads.get(key, { type: "json" });
if (entry === null) { /* 404 */ }
```

`set`/`setJSON` overwrite an existing key. Both return `{ modified, etag }` (`etag` omitted when no new entry was generated).

### Persisting a user upload (Function)

```ts
import { getStore } from "@netlify/blobs";
import type { Context } from "@netlify/functions";
import { v4 as uuid } from "uuid";

export default async (req: Request, context: Context) => {
  const form = await req.formData();
  const file = form.get("file") as File;
  const key = uuid();
  const uploads = getStore("file-uploads");
  await uploads.set(key, file, { metadata: { country: context.geo.country.name } });
  return new Response("Submission saved");
};
```

Edge functions are identical except `import type { Context } from "@netlify/edge-functions";`.

### Reading (Function)

```ts
export default async (req: Request, context: Context) => {
  const { key } = context.params;
  const uploads = getStore("file-uploads");
  const entry = await uploads.get(key);
  if (entry === null) return new Response(`Not found: ${key}`, { status: 404 });
  return new Response(entry);
};
```

## Metadata and conditional reads

```ts
// getWithMetadata: data + metadata + etag; supports conditional reads
const { data, etag, metadata } = await uploads.getWithMetadata(key);

// getMetadata: metadata + etag only, without downloading the blob
const meta = await uploads.getMetadata(key); // { etag, metadata } or null
```

Both return `null` if the key is absent. Both accept `{ consistency, etag, type }`.

**Conditional read:** pass a cached `etag`; if it still matches server-side, `data` is `null` (your copy is fresh). Compare the whole ETag value including surrounding quotes and any weakness prefix.

```ts
const { data, etag } = await uploads.getWithMetadata("my-key", { etag: cachedETag });
if (etag === cachedETag) {
  // data is null — cached copy still fresh
}
```

## Concurrency: atomic conditional writes

**Last write wins — there is no concurrency control.** Do NOT build counters, balances, or read-modify-write logic on a blob key, even with `onlyIfMatch` retries — that is transactional data; use Netlify DB.

`set`/`setJSON` accept `{ onlyIfNew, onlyIfMatch }`:

```ts
// Create only if key does not exist
const { modified } = await emails.set("jane@netlify.com", "Jane Doe", { onlyIfNew: true });
if (!modified) return new Response("Email already exists", { status: 400 });

// Update only if the ETag still matches
const { modified } = await emails.set("jane@netlify.com", "New Jane", { onlyIfMatch: etag });
if (!modified) return new Response("Cached data is stale", { status: 400 });
```

## Listing

```ts
const { blobs } = await uploads.list(); // blobs: [{ etag, key }]
```

`list({ directories, paginate, prefix })`. Group keys hierarchically with `/`:

```ts
const { blobs, directories } = await animals.list({ directories: true });
// directories: ["cats", "dogs"]; blobs: top-level keys only

// Drill in — trailing slash REQUIRED (without it "catsuit" also matches)
const res = await animals.list({ directories: true, prefix: "cats/" });
```

Pagination: `list` returns all pages by default (pages of up to 1,000 entries). Set `paginate: true` for an `AsyncIterator`:

```ts
for await (const page of store.list({ paginate: true })) {
  console.log(page.blobs);
}
```

`listStores({ paginate })` returns `{ stores: string[] }` — **does not include deploy-specific stores** (pages of up to 1,000).

## Deleting

```ts
await uploads.delete(key);                        // resolves undefined
const { deletedBlobs } = await uploads.deleteAll(); // deletes every object = deletes the store
```

## Expiration (no server-side TTL)

Blobs never expire on their own. Store an expiration timestamp in metadata, check it on read, and `delete` when past:

```ts
await uploads.set(key, body, { metadata: { expiration: new Date("2025-01-01").getTime() } });
const entry = await uploads.getWithMetadata(key);
const { expiration } = entry.metadata;
if (expiration && expiration < Date.now()) await uploads.delete(key);
```

## Consistency

Default is **eventual** consistency: writes are globally available immediately, but updates/deletions propagate to all edge locations within 60 seconds. Opt into **strong** consistency per store or per read:

```ts
const store = getStore({ name: "animals", consistency: "strong" }); // whole store
await store.get("dog", { consistency: "strong" });                  // single read
```

Netlify CLI always uses strong consistency.

## Regions

`region` takes an **AWS region code** (not the functions airport code). Supported (any other value throws `InvalidBlobsRegionError` before the request): `ap-southeast-1`, `ap-southeast-2`, `eu-central-1`, `us-east-1`, `us-east-2`.

- **Deploy-specific stores** default to your functions region (auto-injected).
- **Site-wide stores** default to `us-east-2` and do NOT follow your functions region.

**Footgun — site-wide region is per-call:** if you need a site-wide store in a specific region, pass `region` on **every** `getStore` call for that store (reads, writes, deletes). A call that omits it uses `us-east-2` and silently sees no data — no error or warning.

**Footgun — changing a region does not move data:** the store appears empty in the new region while data remains in the old. To migrate, copy each entry to a store opened in the new region, then delete from the old.

```ts
const uploads = getDeployStore({ name: "file-uploads", region: "ap-southeast-2" });
const profiles = getStore({ name: "user-profiles", region: "eu-central-1" });
```

## File-based uploads (no build plugin)

Place files under `.netlify/blobs/deploy` in the base directory; Netlify uploads them (preserving directory structure) to **deploy-specific stores**. Attach metadata with a sibling JSON file named `$<filename>.json` (must be valid JSON or the deploy fails).

```
.netlify/blobs/deploy/
├─ dogs/good-boy.jpg
├─ dogs/$good-boy.jpg.json   # metadata for good-boy.jpg
├─ cat.jpg
└─ mouse.jpg
```

**Caution:** Netlify empties `.netlify/blobs/deploy` before each build. Files committed to your repo are NOT uploaded — create blob files during the build (build command or build plugin).

## Access control (default to private)

Blobs have **no built-in access control** — the serving function is the gate. Blobs are only reachable through your own site's code, encrypted at rest and in transit. When in doubt, default to private: gate reads behind an authenticated function rather than exposing blobs publicly. Do not serve arbitrary user-supplied keys for sensitive data; scope keys with something callers cannot tamper with. Blobs is not part of Netlify's HIPAA-compliant offering.

## Constraints

- Store names: no `/` or `:`, max 64 bytes.
- Keys: non-empty, cannot start with `/`, max 600 bytes, any Unicode (some chars >1 byte).
- Object size max 5 GB; metadata max 2 KB.
- Functions written in **Go cannot access Netlify Blobs**.
- Fetch API required (Node.js 18+); otherwise pass a custom `fetch`: `getStore({ fetch, name: "file-uploads" })`.
- Local dev (Netlify Dev) uses a sandboxed local store: no file-based uploads, cannot read production data.
- File-based uploads require continuous deployment or CLI deploys.

## When an operation fails

Surface the error and read the function logs. Do not invent REST endpoints or side-channel APIs to retry.

## CLI and UI

`netlify blobs:list/get/set/delete` exist for inspection — see the [CLI command reference](https://cli.netlify.com/commands/blobs/). Browse and download in the UI under **Data & Storage > Blobs**.

## Module version migration

If you wrote to site-wide stores with `@netlify/blobs` 6.5.0 or earlier and upgrade, those stores become inaccessible due to a namespacing change. Migrate with the latest CLI, then use module 7.0.0+:

```sh
netlify recipes blobs-migrate YOUR_STORE_NAME
```

## Reference

Full API and background: [Netlify Blobs docs](https://docs.netlify.com/build/data-and-storage/netlify-blobs/) and the [data & storage overview](https://docs.netlify.com/build/data-and-storage/overview/).

<!-- system: agent-context/blobs/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (blobs)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Blobs is not a database. For dynamic, per-user, or transactional data,
   use Netlify DB — Blobs is for objects, files, and cache-like state.
2. When a store operation fails, surface the error and read the function
   logs — do not invent REST endpoints or side-channel APIs to retry.
3. `netlify blobs:list/get/set/delete` exist for inspection; the CLI
   reference is their source of truth — link, don't restate.
4. Blobs have no built-in access control — the serving function is the gate.
   When in doubt, default to private: gate reads behind an authenticated
   function rather than exposing blobs publicly.
5. Site-scoped stores are shared across ALL deploy contexts — code on a
   deploy preview reads, overwrites, and deletes production data. Never run
   destructive tests or seed throwaway data from previews; use
   `getDeployStore()` or a context-specific store name for isolation.
6. Don't build counters, balances, or read-modify-write logic on a blob key —
   even with `onlyIfMatch` retries. That's transactional data; use Netlify DB.
7. Build plugins: state BOTH halves — they can read from any of the site's
   stores, but write only to deploy-specific stores (`getDeployStore`).
netlify-caching14.8 KB

View saved version →

---
name: netlify-caching
description: Cache dynamic and static responses on Netlify's CDN from Functions, Edge Functions, and proxies. Use when you add caching or cache-control headers to a function response, tune cache TTL or stale-while-revalidate, set up the durable cache, vary a cache key by query/header/cookie/country/language, purge or invalidate the cache by site or cache tag, use the programmatic Cache API (caches.open/match/put) or @netlify/cache helpers (fetchWithCache/cacheHeaders/getCacheStatus), speed up an expensive API call, add ISR or on-demand revalidation, or debug why a response is or isn't cached via the Cache-Status header.
---

# Netlify caching

## Cache-control header to reach for

Dynamic responses (Functions, Edge Functions, proxies) are **NOT cached by default** — you must opt in. Set `Netlify-CDN-Cache-Control` on the response:

```ts
import type { Context } from "@netlify/functions";

export default async (req: Request, context: Context) => {
  return new Response("Hello world", {
    headers: {
      'Netlify-CDN-Cache-Control': 'public, durable, max-age=60, stale-while-revalidate=120'
    }
  });
};
```

Header choice (most specific wins; `CDN-Cache-Control`/`Cache-Control` always pass downstream):
- `Netlify-CDN-Cache-Control` — Netlify CDN only. **Reach for this.**
- `CDN-Cache-Control` — all CDNs that support it.
- `Cache-Control` — any CDN or the browser.

**Legacy path to avoid:** On-demand Builders do **not** support these headers or `Netlify-Vary` — they use a TTL pattern and key on URL path only. Don't reach for ODBs in new code.

## Footguns (read first)

- **Only `GET` is cached.** POST/PUT/etc. are never cached regardless of headers — expose cacheable data on a GET route (inputs in the URL or query string).
- **`netlify dev` does not emulate the CDN cache.** A local cache miss every time is expected. Verify caching on a deployed URL (Deploy Preview or production) via its `Cache-Status` header.
- **Without `Netlify-Vary: query=...`, the full query string is the cache key** — every distinct query string (`utm_*`, `fbclid`, …) is a separate cache entry. Enumerate only the params that change the response.
- **Static assets are fresh for up to a year** — a shorter `max-age` is ignored. They change only on a new deploy or manual purge.
- **basic-auth on ANY page disables caching for the ENTIRE site.**
- **`durable` is serverless-only** — it has no effect on Edge Function responses.
- Never opt sensitive content out of automatic invalidation — it can stay publicly cached after deploys/firewall changes.

## Directives

- `public` cache it / `private` browser-only, not Netlify's shared cache / `no-store` don't cache.
- `s-maxage=N` seconds in Netlify's shared cache (overrides `max-age` there).
- `max-age=N` seconds in any cache.
- `stale-while-revalidate=N` serve stale for N seconds after expiry while revalidating in background.
- `durable` (serverless only) store in Netlify's durable cache so other edge nodes reuse it instead of re-invoking the function.

Defaults when no header is set — static: `Netlify-CDN-Cache-Control: public, s-maxage=31536000, must-revalidate`; dynamic: `Cache-Control: public, max-age=0, must-revalidate`.

## Cache key variation — `Netlify-Vary`

Comma-delimited instructions on the response; pipe-delimited value lists:

```
Netlify-Vary: query=item_id|page, country=es+de|us, cookie=ab_test|is_logged_in
```

- `query=a|b` subset, or bare `query` for all params. Keys case-sensitive; param order irrelevant.
- `header=Device-Type|App-Version` — custom + most standard headers.
- `language=en|es+pt` — `+` groups; checked against `Accept-Language` with quality weighting.
- `country=us|es+pt` — GeoIP, ISO 3166-1 two-letter codes; `+` groups.
- `cookie=ab_test|is_logged_in` — target specific keys, not the whole `Cookie` header.

**Cannot vary by header on:** `Accept*`, `Cache-Control`, `Connection`, `Content-Length`, `Cookie`, `Host`, `If-*`, `Range`, `Referer`, `Upgrade`, `User-Agent`. For language/cookie/format use `Vary: Accept-Language`/`Vary: Cookie` or the specific `Netlify-Vary` instruction.

**Consistency rule:** a URL must return the same `Netlify-Vary` on every response — the first cached response's instructions win and later ones are ignored. `Netlify-Vary` + standard `Vary` are both respected (use `Vary` for format/encoding, and to pass instructions to an upstream CDN like Cloudflare).

## Cache tags & opt-out

Tag responses for taggable purging:

```
Netlify-Cache-Tag: tag1,tag2,tag3
```

- `Netlify-Cache-Tag` (Netlify CDN) wins over `Cache-Tag` (passed downstream). Some providers strip `Cache-Tag` — set both when proxying through them.
- Constraints: case-insensitive, UTF-8 only, ≤1024 chars/tag, ≤500 tags/response.

Opt a response out of automatic atomic-deploy invalidation with `Netlify-Cache-ID` (comma-separated; auto-registered as cache tags for purging; separate 500-ID limit):

```
Netlify-Cache-ID: cms-proxy,product,image
```

After opting out, purge on-demand after relevant changes (e.g. redirect/proxy or function changes behind a `Netlify-Cache-ID`).

## On-demand invalidation (purge)

Purge from a **deployed function** with `purgeCache` (site ID is passed automatically):

```ts
import { purgeCache } from "@netlify/functions";

export default async () => {
  await purgeCache(); // no args = purge everything for the site
  return new Response("Purged!", { status: 202 });
};
```

Purge by tag, optionally targeting a deploy/subdomain:

```ts
import { purgeCache } from "@netlify/functions";

export default async (req: Request) => {
  const cacheTag = new URL(req.url).searchParams.get("tag");
  if (!cacheTag) return;
  await purgeCache({
    tags: [cacheTag],
    deployAlias: "deploy-preview-11",
    domain: "early-access.company.com",
  });
  return new Response("Purged!", { status: 202 });
};
```

**Ambient credentials only work inside a deployed function.** From CI, local scripts, or the build, pass `token` (a personal access token read from an env var — never hardcoded) and `siteID`.

**Lambda-compatible functions** use the legacy `module.exports.handler = async (event, context) => {…}` signature and must pass `context.clientContext.custom.purge_api_token`:

```ts
import { purgeCache } from "@netlify/functions";

module.exports.handler = async (event, context) => {
  const token = context.clientContext.custom.purge_api_token;
  await purgeCache({ tags: ["tag1", "tag2"], token });
  return { body: "Purged!", statusCode: 202 };
};
```

Direct API (from outside a function) — `POST https://api.netlify.com/api/v1/purge` with `Authorization: Bearer <personal_access_token>` and `Content-Type: application/json`:

```sh
curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <personal_access_token>" \
  --data '{"site_slug": "mysitename", "cache_tags": ["news"], "deploy_alias": "deploy-preview-11", "domain": "early-access.company.com"}' \
  'https://api.netlify.com/api/v1/purge'
```

- Purge by site: `site_id` or `site_slug`. By tag: `cache_tags` + site. Omitting `cache_tags` purges the whole site; an **empty** `cache_tags` list purges NOTHING.
- Identifier mapping: in the UI (Project configuration > General > Project details), **Project ID** = `site_id`, **Project name** = `site_slug`. See https://docs.netlify.com/api-and-cli-guides/api-guides/get-started-with-api#get-site.
- **Rate limit:** each tag or site can be purged only twice per 5s — exceeding returns `429`.

## Cache API (`caches` global)

Programmatic read/write of HTTP responses from Functions/Edge Functions. Use for caching individual components of a route or arbitrary fetches, alongside header-based route caching.

**Scope rule:** `caches.open()` anywhere, but `match`/`put`/`delete` **only inside the request handler** — doing them at module/global scope throws.

```ts
import type { Config, Context } from "@netlify/functions";

const cache = await caches.open("my-cache"); // ok in global scope

export default async (req: Request, context: Context) => {
  const request = new Request("https://example.com/expensive-api");
  const cached = await cache.match(request);
  if (cached) return cached;

  const fresh = await fetch(request);
  if (fresh.ok) {
    cache.put(request, fresh.clone()).catch((error) => {
      console.error("Failed to add to the cache:", error);
    });
  }
  return fresh;
};

export const config: Config = { path: "/cache-api-example" };
```

`CacheStorage` subset:
- `caches.match(request)` → `Response` from any cache, or `undefined`.
- `caches.open(name)` → `Cache`. Distinct names fragment the cache and lower hit ratio — use few, meaningful names.

`Cache` methods (all require `caches.open()`):
- `cache.match(request)` → `Response` | `undefined`.
- `cache.put(request, response)` → adds a response.
- `cache.add(request)` / `cache.addAll(requests)` → fetch + store.
- `cache.delete(request)` → `true`.
- `keys()` is **not implemented** — no way to list contents.

Consistency: reads/writes strongly consistent; **deletes eventually consistent** (a deleted entry may still return briefly).

**Cannot cache:** partial responses (206), `Vary: *`, or non-`GET` methods. Responses need a cache-control header with `max-age`/`s-maxage` ≥ 1s, `public` (not `private`/`no-cache`/`no-store`), and a 2xx status — otherwise storage errors. For responses you don't control, rewrite headers with `fetchWithCache`.

**Limits per invocation:** 100 lookups, 20 insertions/deletions. Exceeding: further lookups return nothing; writes/deletes no-op. Limits are shared across edge functions in a request but separate between serverless and edge functions. Cache data is per-region (not replicated), auto-invalidated on redeploy and on `max-age`/`s-maxage` expiry.

## `@netlify/cache` module

Install to get helpers, time constants (`MINUTE`/`HOUR`/`DAY`), and a `caches` export for local dev:

```
npm install @netlify/cache
```

**Local-dev workaround:** the `caches` global isn't part of Node.js. Netlify provides it in its Functions/Edge runtimes (live and under `netlify dev`), but if you run your framework's own dev server the global is undefined and throws — import it instead:

```ts
import { caches } from "@netlify/cache";
const cache = await caches.open("my-cache");
```

Requires Netlify CLI 20.0.3+; nothing persists locally (lookups return nothing, writes/deletes don't mutate). No functional change from the global.

### `cacheHeaders(settings)` → header object

```ts
import { cacheHeaders, DAY } from "@netlify/cache";

const headers = {
  "x-custom-header": "some value",
  ...cacheHeaders({
    ttl: 2 * DAY,          // s-maxage
    swr: HOUR,             // stale-while-revalidate
    durable: true,
    tags: ["product", "sale"],
    overrideDeployRevalidation: ["tag"], // opt out of atomic-deploy invalidation
    vary: {
      cookie: ["ab_test_name", "ab_test_bucket"],
      query: ["item_id", "page"], // or true for all
      country: ["us", ["es", "pt"]], // nested = OR
      language: ["en"],
      header: ["Device-Type"],
    },
  }),
};
```

For only generic (non-Netlify) headers, use the `cdn-cache-control` npm module instead.

### `fetchWithCache(resource, options?, cacheSettings?)`

Drop-in `fetch` that returns a cached response or fetches, stores, and returns. `cacheSettings` override conflicting response headers; with `swr`, background revalidation is handled automatically.

```ts
import { fetchWithCache, DAY } from "@netlify/cache";

const response = await fetchWithCache("https://example.com/expensive-api", {
  ttl: 2 * DAY,
  tags: ["product", "sale"],
  vary: { cookie: ["ab_test_name"], query: ["item_id", "page"] },
});
```

### `getCacheStatus(response | headers | headerString)`

Returns `{ hit, caches: { durable: { hit, stale, stored, ttl }, edge: { hit, stale } } }`.

```ts
const { hit, edge, durable } = getCacheStatus(response);
```

### `needsRevalidation(response)` → boolean

Only needed when calling `cache.match`/`cache.put` directly (not with `fetchWithCache`+`swr`). True when a Cache-API response is stale within its SWR window — return it, then revalidate in `context.waitUntil` and `cache.put` the fresh copy:

```ts
if (cached) {
  if (needsRevalidation(cached)) {
    context.waitUntil(
      fetch(request).then((fresh) => {
        const response = new Response(fresh.body, {
          headers: { ...Object.fromEntries(fresh.headers), ...cacheHeaders({ ttl: MINUTE, swr: HOUR }) },
        });
        return cache.put(request, response);
      })
    );
  }
  return cached;
}
```

## Durable cache

Add `durable` (serverless only) so edge nodes lacking a local copy check the shared durable cache before invoking the function — fewer invocations, better cache-miss latency. Eventually consistent, so multiple regions may still invoke the function a few times per version. Co-located with the site's functions region. Works with `Netlify-Vary`, SWR, and on-demand invalidation. **Next.js:** Next Runtime 5.5.0+ uses the durable cache automatically.

## Debugging with `Cache-Status`

Netlify sets `Cache-Status` (RFC 9211) on all responses. Check it on a **deployed** URL. Look for values starting `"Netlify Edge"` or `"Netlify Durable"`:

- `"Netlify Edge"; fwd=miss` — nothing cached.
- `"Netlify Edge"; hit` — served from cache.
- `"Netlify Edge"; hit; fwd=stale` — stale served while revalidating (SWR).
- Durable stored on miss: `"Netlify Durable"; fwd=uri-miss; stored=true; ttl=3600`.
- Durable hit: `"Netlify Durable"; hit; ttl=1234`.

`ttl` negative = seconds since expiry. Each request may hit a different cache instance — without production traffic or `durable`, expect several empty caches before a hit; repeat requests to warm one.

<!-- Gaps: package/method inconsistency in @netlify/cache local-dev docs (caches import shown with cache.set, not the documented cache.put) resolved to cache.put per Cache API surface. -->

<!-- system: agent-context/caching/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (caching)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Only `GET` responses are cached by the CDN. `POST`/`PUT`/etc. are never
   cached regardless of headers — expose cacheable data on a `GET` route
   (put the inputs in the URL or query string).
2. Without `Netlify-Vary: query=...`, the full query string is the cache key —
   every distinct query string (`utm_*`, `fbclid`, ...) is a separate cache
   entry. Enumerate only the params that actually change the response.
3. `netlify dev` does not emulate the CDN cache — a cache miss every time
   locally is expected, not a bug. Verify caching behavior on a deployed URL
   (Deploy Preview or production) via its `Cache-Status` header.
4. `purgeCache()` has ambient credentials only inside a deployed function.
   From CI, local scripts, or the build, pass `token` (a personal access
   token read from an env var, never hardcoded) and `siteID`.
netlify-cli-and-deploy4.18 KB

View saved version →

---
name: netlify-cli-and-deploy
description: Guide for using the Netlify CLI and deploying sites. Use when installing the CLI, linking sites, deploying (Git-based or manual), managing environment variables, or running local development. Covers netlify dev, netlify deploy, Git vs non-Git workflows, and environment variable management.
---

# Netlify CLI and Deployment

## Installation

```bash
npm install -g netlify-cli    # Global (for local dev)
npm install netlify-cli -D    # Local (for CI)
```

Requires Node.js 18.14.0+.

## Authentication

```bash
netlify login       # Opens browser for OAuth
netlify status      # Check auth + linked site status
```

For CI, set `NETLIFY_AUTH_TOKEN` environment variable instead.

## Linking a Site

Check if already linked with `netlify status`. If not:

```bash
# Interactive
netlify link

# By Git remote (if using Git)
netlify link --git-remote-url https://github.com/org/repo

# Create new site
netlify init           # With Git CI/CD setup
netlify init --manual  # Without Git CI/CD
```

Site ID is stored in `.netlify/state.json`. Add `.netlify` to `.gitignore`.

## Deploying

### Git-Based Deploys (Continuous Deployment)

Set up with `netlify init`. Automatic deploys trigger on push/PR:
- Push to production branch → production deploy
- Open PR → deploy preview with unique URL
- Push to other branches → branch deploy

Build runs on Netlify's servers. Configure build settings in `netlify.toml`.

### Manual / Local Deploys (No Git Required)

Build locally, then upload:

```bash
netlify deploy          # Draft deploy (preview URL)
netlify deploy --prod   # Production deploy
netlify deploy --dir=dist  # Specify output directory
```

This works without Git — useful for prototypes, local-only projects, or CI pipelines.

## Local Development

### Option 1: netlify dev

```bash
netlify dev
```

Wraps your framework's dev server and provides:
- Environment variable injection
- Functions and edge functions
- Redirects and headers processing

### Option 2: Netlify Vite Plugin (Vite-based projects)

For projects using Vite (React SPA, TanStack Start, SvelteKit, Remix), the Vite plugin provides Netlify platform primitives directly in the framework's dev server:

```bash
npm install @netlify/vite-plugin
```

```typescript
// vite.config.ts
import netlify from "@netlify/vite-plugin";
export default defineConfig({ plugins: [netlify()] });
```

Then run your normal dev command (`npm run dev`) — no `netlify dev` wrapper needed. This gives you access to Blobs, DB, Functions, and environment variables during development.

See the **netlify-frameworks** skill for framework-specific local dev guidance.

## Environment Variables

### CLI Management

```bash
# Set
netlify env:set API_KEY "value"
netlify env:set API_KEY "value" --secret              # Hidden from logs
netlify env:set API_KEY "value" --context production   # Context-specific

# Get
netlify env:get API_KEY

# List
netlify env:list
netlify env:list --plain > .env                        # Export to file

# Import from file
netlify env:import .env

# Delete
netlify env:unset API_KEY
```

### Context Scoping

Variables can be scoped to deploy contexts:

```bash
netlify env:set API_URL "https://api.prod.com" --context production
netlify env:set API_URL "https://api.staging.com" --context deploy-preview
netlify env:set DEBUG "true" --context branch:feature-x
```

### Accessing in Code

- **Server-side (Functions)**: Use `Netlify.env.get("VAR")` (preferred) or `process.env.VAR`
- **Client-side (Vite)**: Only `VITE_`-prefixed vars via `import.meta.env.VITE_VAR`
- **Client-side (Astro)**: Only `PUBLIC_`-prefixed vars via `import.meta.env.PUBLIC_VAR`

**Never use `VITE_` or `PUBLIC_` prefix for secrets** — these are exposed to the browser.

## Useful Commands

| Command | Description |
|---|---|
| `netlify status` | Auth and site link status |
| `netlify dev` | Start local dev server |
| `netlify build` | Run build locally (mimics Netlify environment) |
| `netlify deploy` | Draft deploy |
| `netlify deploy --prod` | Production deploy |
| `netlify dev:exec <cmd>` | Run command with Netlify environment loaded |
| `netlify env:list` | List environment variables |
| `netlify clone org/repo` | Clone, link, and set up in one step |

Referenced files: 4

netlify-config15.8 KB

View saved version →

---
name: netlify-config
description: Configure Netlify builds and routing via netlify.toml, _redirects, and _headers. Use when setting a build command or publish directory, adding redirects or rewrites or proxies, adding an SPA fallback rewrite, setting custom response headers or basic auth, managing environment variables and secrets, scoping vars per deploy context, marking a var as secret, disabling secret scanning, configuring functions bundling, ignoring builds, or wiring up a monorepo or JavaScript SPA on Netlify.
---

# Netlify configuration

Config lives in three files at the repo **root** (or the base/package directory for monorepos):
- `netlify.toml` — build, contexts, plugins, functions, redirects, headers, dev.
- `_redirects` — plain-text redirect/rewrite rules, saved to the **publish directory**, no extension.
- `_headers` — plain-text response headers, saved to the **publish directory**.

`netlify.toml` values **take precedence over the Netlify UI** when they conflict. Paths in `netlify.toml` are absolute relative to the **base directory** (root `/` by default).

## Modern vs legacy syntax to reach for
- Functions bundler: use `node_bundler = "esbuild"`. `zisi` is the legacy JS default; TypeScript always uses `esbuild`.
- Temporary redirect: use `status = 302`. `307` is **unsupported**.
- Gatsby Image CDN: use `NETLIFY_IMAGE_CDN`, not the deprecated `GATSBY_CLOUD_IMAGE_CDN`.
- Injecting env values into TOML: `key = "$VAR"` is **NOT supported** (except `signed` in proxy redirects). Use a build-command `sed` substitution or a build plugin (see below).

## `netlify.toml` build + contexts

```toml
[build]
  base = "frontend"
  publish = "dist"
  command = "npm run build"
  environment = { NODE_VERSION = "18" }

[context.production]
  publish = "output/"
  command = "make publish"

[context.deploy-preview]
  publish = "dist/"

[context."feat/branch"]        # quote names with special characters
  command = "npm run preview"
```

`[build]` runs in **Bash**. Context-aware keys include `[build]` and `[[plugins]]` — but **NOT** `[[redirects]]` or `[[headers]]` (those are always global). Precedence, least→most specific: UI < toml < any-context property < `[context.<name>]` < `[context.branchname]`.

## Redirects and rewrites

`_redirects` rules are processed **first**, then `netlify.toml`; within each, the **first matching rule top-to-bottom wins** — list specific rules before general ones. Edge functions run before redirects.

SPA history-`pushState` fallback (required for clean URLs):
```
/*  /index.html  200
```
```toml
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200
```

`_redirects` syntax — `from to [status] [conditions]`, `#` comments, paths case-sensitive, URL-encode special chars:
```
/home         /              301
/my-redirect  /              302
/ecommerce    /store-closed  404          # custom 404 for a path
/pass-through /index.html    200          # rewrite
/best-pets/dogs /best-pets/cats.html 200! # force/shadow (! or force=true)
/news/*  /blog/:splat                     # splat
/news/:month/:date/:year/:slug  /blog/:year/:month/:date/:slug   # placeholders
/store id=:id  /blog/:id  301             # query params
/  /anz  302  Country=au,nz               # no spaces in value list
/israel/*  /israel/he/:splat  302  Language=he
/* /legacy/:splat 200 Cookie=is_legacy,my_other_cookie
```

`[[redirects]]` keywords: `from`, `to`, `status` (default `301`), `force` (default `false`; `!`/shadow), `query` (`query = {path = ":path"}`), `conditions` (`{Language, Country, Role, Cookie}`), `headers` (proxy request headers), `signed` (env var name for signed proxies).

**Gotchas:**
- You **cannot** add/remove a trailing slash with a redirect — CDN normalizes URLs first; a `/x/ → /x 301!` rule loops infinitely. Rely on Pretty URLs (default on).
- Splat asterisks work only at the **end** of a segment (`/jobs/*`), not mid-path (`/jobs/*.html` invalid). Placeholders (`:x`) only at the start of a segment; can't mix wildcard+placeholder in one segment.
- You can't exclude a path from a splat; put a more specific rule first.
- `Country` = ISO 3166-1 alpha-2; language redirects match only the **first** `Accept-Language` entry.
- Role-based redirects with external auth providers are **Enterprise-only**.
- 10,000+ redirects: use wildcards/placeholders or Edge Functions — oversized serialized output fails the deploy.

## Proxies

```
/api/*           https://api.example.com/:splat        200
/netlify-site/*  https://my-other-site.netlify.app/:splat  200   # use .netlify.app, not custom domain
```
```toml
[[redirects]]                     # custom request headers + force
  from = "/search"
  to = "https://api.mysearch.com"
  status = 200
  force = true
  headers = {X-From = "Netlify"}
```
Signed proxy (`signed` names an env var scoped to **Runtime**; must live in `netlify.toml`; JWS is external-only, not Netlify→Netlify):
```toml
[[redirects]]
  from = "/search"
  to = "https://api.mysearch.com"
  status = 200
  force = true
  signed = "API_SIGNATURE_TOKEN_PLACEHOLDER"
```

**Gotchas:** cross-team rewrites disallowed; same-password-site rewrites OK but not across separate protected sites; proxy timeout **26 s**; one hop by default; relative-path assets break (use absolute or `<base>`); loops silently ignored.

## Custom headers

```
/*
  X-Frame-Options: DENY
/templates/index2.html
  X-Frame-Options: SAMEORIGIN
```
Multi-value — repeat the key (`_headers`) or a multiline TOML string:
```toml
[[headers]]
  for = "/*"
  [headers.values]
  cache-control = '''
  max-age=0,
  no-cache,
  no-store,
  must-revalidate'''
```

**Gotchas:**
- Headers in `_headers`/`netlify.toml` are **global** — NOT scoped to branch/context. Workaround: strip global headers, keep header files in a custom dir, and `cp` them into the publish dir from a per-context build command:
  ```toml
  [context.staging]
    command = "npm run build && cp ./custom-headers/_stagingHeaders ./dist/_headers"
  ```
- Headers apply only to files from Netlify's store — **NOT** to proxied content or function/edge (SSR) responses; those must set their own headers.
- Ignored (server-set) names include `Content-Length`, `Content-Encoding`, `Location` (use redirects), `Set-Cookie`, `Server`, etc.
- Basic auth headers: **Pro/Enterprise only**. Cross-subdomain cookies need a custom domain (`netlify.app` is on the Public Suffix List).

## Functions

```toml
[functions]
  directory = "myfunctions/"          # default: <base>/netlify/functions
  node_bundler = "esbuild"
  external_node_modules = ["package-1"]  # esbuild only; native add-ons etc.
  included_files = ["files/*.md"]        # ! prefix excludes

[functions."api_*"]                    # glob/named blocks concatenate with top-level
  external_node_modules = ["package-2"]
  included_files = ["!files/post-1.md"]
```

## Environment variables

Two storage methods:
- **UI / CLI / API** — stored on Netlify (not the repo). Supports site + shared vars, per-context values, scopes; reaches builds, functions/edge/ODB, snippet injection, forms, signed proxies. **Recommended for anything sensitive.**
- **`netlify.toml`** — stored in the repo. Site vars only, per-context values, **no scope selection** (everything gets **Builds** + **Post processing**), reaches builds + snippet injection only.

`netlify.toml` env vars **override** same-key UI/CLI/API vars.

Per-context values in TOML:
```toml
[context.production]
  environment = { NODE_VERSION = "14.15.3" }
[context.deploy-preview.environment]
  NOT_PRIVATE_ITEM = "not so secret"
[context.branch-deploy.environment]
  NODE_ENV = "development"
```

CLI:
```bash
netlify env:set KEY value          # --secret marks it a secret
netlify env:import .env             # site vars; --replace-existing wipes others first
netlify env:unset KEY
netlify env:list --plain --context production > .env
netlify build                       # local build with Netlify's env vars
```
API: `createEnvVars` / `updateEnvVar` (`is_secret: true`) / `setEnvVarValue` / `deleteEnvVar` / `deleteEnvVarValue`.

**Access syntax:** Bash `$VAR` in `build.command`/`ignore.command`; `process.env.VAR` in Node scripts and plugins.

**Scopes** (Pro/Enterprise; default all): Builds (site builds) · Functions (Functions/Edge/ODB) · Runtime (forms, signed proxies) · Post processing (snippet injection). Shared vars are Pro/Enterprise and **Team-Owner-only** to read/edit. Precedence for a site+shared key collision resolves **per scope** — a site var only wins within the scopes it actually carries.

**Naming/limits:** keys alphanumeric + underscore, must start with a letter (`1KEY`, `_KEY1` invalid); keys ≤255 chars, values ≤5,000 chars. Read-only variable names are reserved. Changes need a build + deploy.

**Set the build language via reserved config vars** — `NODE_VERSION`, `NPM_FLAGS`, `YARN_VERSION`, `BUN_VERSION`, `RUBY_VERSION`, `PHP_VERSION`, `PYTHON_VERSION`, `GO_VERSION`, `HUGO_VERSION`, `PNPM_FLAGS`, `NPM_TOKEN` (Yarn: `YARN_NPM_AUTH_TOKEN`), etc.

**Must be set in UI/CLI/API, NOT `netlify.toml`** (read after the repo is cloned or a runtime-only var): `AWS_LAMBDA_JS_RUNTIME`, `GIT_LFS_ENABLED`, `GIT_LFS_FETCH_INCLUDE`, `NETLIFY_BUILD_DEBUG`.

**`CI` gotcha:** defaults to `true`; if it breaks a build, prepend `CI='' ` to the build command.

### Injecting env values into headers/redirects
`key = "$VAR"` is unsupported. Only path (scope must include **Builds**):
```toml
[build]
  command = "sed -i \"s|HEADER_PLACEHOLDER|${PROD_API_LOCATION}|g\" netlify.toml && yarn build"
```
`sed` substitution works **only** for `[[headers]]`/`[[redirects]]` (read after the build) and is **not** visible to build plugins (they run before the build command). For plugin-visible changes, use a local build plugin editing `netlifyConfig`.

### Useful read-only build vars
`CONTEXT` (`production`/`deploy-preview`/`branch-deploy`/`dev`), `BRANCH`, `COMMIT_REF`, `CACHED_COMMIT_REF`, `PULL_REQUEST`, `REVIEW_ID`, `URL`, `DEPLOY_URL`, `DEPLOY_PRIME_URL`, `SITE_ID`, `SITE_NAME`.

## Secrets Controller

Flag a var as secret: `Contains secret values` (UI) / `--secret` (CLI) / `is_secret: true` (API). Enforced, non-customizable policy:
- Secret values are **write-only** — no readable version after set; the flag can't be removed to reveal it.
- Secrets need explicit contexts + scopes; **cannot** carry the `post processing` scope.
- Only code on Netlify (edge/serverless/build) reads unmasked values; off-Netlify sees masked. The `dev`-context value is exempt (unmasked from UI/CLI/API); `netlify build` never emits raw values.

**Secret scanning** runs automatically once any var is secret (and via smart detection). Fails the build on detection and logs the location. Configure via env vars set per context:
- `SECRETS_SCAN_ENABLED=false` — disables **all** scanning (loses all secret protection).
- `SECRETS_SCAN_SMART_DETECTION_ENABLED=false` — disables smart detection only.
- `SECRETS_SCAN_OMIT_KEYS`, `SECRETS_SCAN_OMIT_PATHS` (comma lists; paths from repo root, globs OK).
- `SECRETS_SCAN_SMART_DETECTION_OMIT_VALUES` — safelist false positives (**prefer** this over disabling). Smart detection is Personal/Pro/Enterprise.

Scanning covers all build files, values >4 chars and non-boolean, searching plaintext + base64 + URI-encoded permutations.

### Sensitive variable policy (public repos only)
Governs whether **untrusted** deploys (unrecognized authors) get sensitive vars. Site members' Git deploys are always trusted, even from forks. Set at Project configuration > Environment variables > Site policies:
- **Require approval** (default) — untrusted deploys wait for a member's approval.
- **Deploy without sensitive variables** — builds run, sensitive vars withheld.
- **Deploy without restrictions** — all vars present.

NOT available for GitHub Enterprise Server / GitLab self-managed repos (treated as private).

## Ignore builds

`ignore` under `[build]` decides whether to rebuild — runs from the base directory in Bash (or Node.js 18, fixed; site `package.json` deps **not** available). **Exit `1` = changed → build continues; exit `0` = no change → build stops.** A build hook always builds regardless of exit code.
```toml
[build]
  ignore = "git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF packages/blog-1 packages/common"
```
```toml
[build]
  ignore = "node ignore_build.js"   # separate file paths must start with ./
```
```js
// ignore_build.js
process.exitCode = process.env.BRANCH.includes("debug") ? 0 : 1
```

## Monorepos

Set the site subdirectory as the **package directory** (keep its `netlify.toml` there), leave base at root `/`, declare deps at the subdirectory level. Package directory is **UI-only — cannot be set in `netlify.toml`** (Project configuration > Developer settings > Continuous deployment > Build settings). Config file discovery order: package dir → base dir → root. Paths in `netlify.toml` stay absolute relative to the base directory. `netlify <cmd> --filter <site>` selects a site.

## JavaScript SPAs

Build command `npm run <script>` / `yarn <script>`; publish dir often `dist` (framework-dependent). Add the `/*  /index.html  200` fallback (above) for `pushState` routing. Code splitting + hashed filenames with atomic deploys can throw `Uncaught SyntaxError: Unexpected token` on stale references — disable hashed filenames, use permalinks, or a service worker.

## Netlify Dev `[dev]`

Does **NOT** run in Bash (no Bash syntax in `command`). There is **no `environment` key** — set local env vars under `[context.dev.environment]`.
```toml
[dev]
  command = "yarn start"
  targetPort = 3000        # if both command + targetPort set, framework must be "#custom"
  port = 8888
  framework = "#custom"
  [dev.https]
    certFile = "cert.pem"
    keyFile = "key.pem"
```

## Plugins & extensions

```toml
[[plugins]]
package = "netlify-plugin-check-output-for-puppy-references"
  [plugins.inputs]
  breeds = ["pomeranian", "chihuahua"]

[[integrations]]              # build-time extension; install on team first
  name = "abc-performance-extension"
  [integrations.config]
    output_path = "reports/performance-reports.html"
```

Full reference pages: build environment variables at https://docs.netlify.com/build/configure-builds/environment-variables.md, env-var overview at https://docs.netlify.com/build/environment-variables/overview.md, Secrets Controller at https://docs.netlify.com/build/environment-variables/secrets-controller.md, redirects at https://docs.netlify.com/manage/routing/redirects/overview.md, redirect options at https://docs.netlify.com/manage/routing/redirects/redirect-options.md, rewrites/proxies at https://docs.netlify.com/manage/routing/redirects/rewrites-proxies.md, custom headers at https://docs.netlify.com/manage/routing/headers.md, and file-based config at https://docs.netlify.com/build/configure-builds/file-based-configuration.md.

<!-- Plan gating for the sensitive variable policy itself is unspecified in the sources; only its public-repo requirement and the smart-detection plan list are documented. -->

<!-- system: agent-context/config/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (config)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Env vars set in `netlify.toml` are NOT available to functions or edge
   functions at runtime — reading them there returns `undefined`. Set
   runtime vars in the UI or with `netlify env:set`, not `netlify.toml`.
2. Never put secrets in client-prefixed env vars (`VITE_`, `NEXT_PUBLIC_`,
   `PUBLIC_`, ...) — they are inlined into the client bundle; `--secret`
   does not protect them.
3. When snapshotting env vars locally (`netlify env:list --plain > .env`),
   keep `.env` gitignored — never commit it.
4. State env-var scope interaction explicitly: a site variable scoped to
   Builds does not shadow the shared variable for other scopes — precedence
   resolves independently per scope (site beats shared only within the
   scopes the site variable actually carries).
netlify-database19 KB

View saved version →

---
name: netlify-database
description: Zero-config Postgres for Netlify apps via @netlify/database — querying data from Functions/Edge Functions, writing schema migrations, setting up Drizzle ORM, local dev with netlify dev, database branches for deploy previews, and migrating an existing Postgres project onto Netlify. Use when adding a database, building a contact form or CRUD API, writing SQL migrations, wiring up Drizzle, running netlify database commands, testing with a local Postgres, or switching from Neon/Supabase/RDS to Netlify Database.
---

# Netlify Database

Zero-config managed Postgres. Install `@netlify/database`, write migrations under `netlify/database/migrations/`, deploy — Netlify provisions the DB and applies migrations automatically. Queryable from Functions, Edge Functions, Builds, and Agent Runners.

## Modern client (reach for this)

```ts
import { getDatabase } from "@netlify/database";

const db = getDatabase();               // auto-selects connection for the runtime
const userId = 42;
const users = await db.sql`SELECT * FROM users WHERE id = ${userId}`;  // auto-parameterized
```

Own driver / ORM instead:
```ts
import { getConnectionString } from "@netlify/database";
const connectionString = getConnectionString();  // correct branch for this env
```

**Legacy — do NOT use for new code:** `import { neon } from "@netlify/neon"`. Superseded by `@netlify/database`. Replace `neon()` calls with the Drizzle `netlify-db` adapter or a Postgres driver via `getConnectionString()`. The legacy env var `NETLIFY_DATABASE_URL` is replaced by `NETLIFY_DB_URL`.

## Where things go

| What | Location |
|------|----------|
| Migrations | `netlify/database/migrations/` (SQL files or subdirs with `migration.sql`) |
| Query code | Functions (`netlify/functions/`), Edge Functions |
| Drizzle schema | `db/schema.ts` (convention) |
| Drizzle client | `db/index.ts` (convention) |
| Connection string | `NETLIFY_DB_URL` env var, or `getConnectionString()` |

## Querying

`getDatabase(options?)` returns a client with `sql` and `pool`. `options.connectionString` overrides the auto-provisioned one; `options.debug` enables logging.

```ts
const db = getDatabase();
const active = await db.sql`SELECT * FROM users WHERE active = ${true}`;
await db.sql`INSERT INTO users (name, email) VALUES (${"Ada"}, ${"ada@example.com"})`;
await db.sql`UPDATE users SET name = ${"Ada Lovelace"} WHERE id = ${1}`;
await db.sql`DELETE FROM users WHERE id = ${1}`;

// Type the rows
interface User { id: number; name: string; email: string; }
const typed = await db.sql<User>`SELECT * FROM users`;

// Stream
for await (const row of db.sql`SELECT * FROM users`.stream()) { /* ... */ }
for await (const chunk of db.sql`SELECT * FROM users`.chunked(100)) { /* ... */ }
```

`SQLTemplate` methods: `execute()` → `Promise<T[]>`, `stream()` → `AsyncGenerator<T>`, `chunked(n)` → `AsyncGenerator<T[]>`, `toSQL()` → raw SQL + params without executing.

`sql` helpers:
- `sql.identifier(value)` — safe table/column name. String, string[], or `{ schema, table, column, as }`.
- `sql.values(rows)` — bulk-insert values list from a 2D array.
- `sql.default` — the SQL `DEFAULT` keyword.
- `sql.raw(value)` — **injects unparameterized SQL; bypasses injection protection. Only for trusted constants (e.g. `"DESC"`), never user input.**
- `sql.unsafe(query, params?, { rowMode })` — raw query string with `$1` params; `rowMode` is `"array"` or `"object"`.

### Transactions — use `pool`

`db.pool` is a [`pg.Pool`](https://node-postgres.com/apis/pool). `BEGIN`/queries/`COMMIT` must run on the same connection:
```ts
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();
}
```

Own drivers:
```ts
import { getConnectionString } from "@netlify/database";
import pg from "pg";
const pool = new pg.Pool({ connectionString: getConnectionString() });

// or the `postgres` driver via env var
import postgres from "postgres";
const sql = postgres(process.env.NETLIFY_DB_URL);
```

## Drizzle ORM

**Install both packages from `@beta` — required.** `latest` lacks the `drizzle-orm/netlify-db` adapter and will fail.
```bash
npm install @netlify/database drizzle-orm@beta
npm install -D drizzle-kit@beta
```

`drizzle.config.ts` — you **MUST** set `out` to the Netlify migrations directory or Netlify won't apply generated migrations:
```ts title="drizzle.config.ts"
import { defineConfig } from "drizzle-kit";
export default defineConfig({
  dialect: "postgresql",
  schema: "./db/schema.ts",
  out: "netlify/database/migrations",   // NOT the default "drizzle"
});
```

```ts title="db/schema.ts"
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
  id: serial().primaryKey(),
  name: text().notNull(),
  email: text().notNull().unique(),
  createdAt: timestamp().defaultNow(),
});
```

```ts title="db/index.ts"
import { drizzle } from "drizzle-orm/netlify-db";  // native adapter, auto-configured
import * as schema from "./schema";
export const db = drizzle({ schema });
```

```ts title="netlify/functions/api.ts"
import { desc } from "drizzle-orm";
import type { Config, Context } from "@netlify/functions";
import { db } from "../../db";
import { users } from "../../db/schema";

export default async (req: Request, context: Context) => {
  if (req.method === "GET") {
    const allUsers = await db.select().from(users).orderBy(desc(users.createdAt));
    return Response.json(allUsers);
  }
  if (req.method === "POST") {
    const { name, email } = await req.json();
    const [user] = await db.insert(users).values({ name, email }).returning();
    return Response.json(user, { status: 201 });
  }
  return new Response("Method not allowed", { status: 405 });
};

export const config: Config = { path: "/api/users" };
```

Generate migrations after editing the schema: `npx drizzle-kit generate`.

**Never run `drizzle-kit push` against a Netlify-hosted database, and never run `drizzle-kit migrate` against `NETLIFY_DB_URL`.** Schema reaches hosted DBs only as committed migration files applied by the deploy. `generate` writes files; the deploy applies them.

## Migrations

Files live in `netlify/database/migrations/`. Two formats:
```text
netlify/database/migrations/20260301143000_create_users.sql          # single SQL file
netlify/database/migrations/20260318091500_add_posts/migration.sql   # subdir form
```

Naming: `<number>_<slug>` — `number` is digits (timestamp or `0001`…) defining order; `slug` is lowercase letters/numbers/hyphens/underscores. Sorted **lexicographically**, applied in order. **Use timestamp prefixes** (`netlify database migrations new` handles this) to avoid out-of-order rejection.

```sql title="netlify/database/migrations/20260425103000_create_comments.sql"
CREATE TABLE comments (
  id SERIAL PRIMARY KEY,
  post_id INTEGER NOT NULL REFERENCES posts(id),
  author_id INTEGER NOT NULL REFERENCES users(id),
  body TEXT NOT NULL,
  created_at TIMESTAMP DEFAULT NOW()
);
```

**When applied:**
- Production deploy: applied immediately before publish; a failure blocks publish. With auto-publish off, Netlify waits for manual publish before applying.
- Deploy preview: applied on every deploy before it goes live; a failure fails the deploy.
- Local: **not** automatic — run `netlify database migrations apply` yourself.

**Migration footguns (all detected as drift / rejected):**
- **Never edit an applied migration** — checksum drift: `migration "<name>" has been modified after being applied`. Write a new corrective migration.
- **Never remove an applied migration** — `... has been removed after being applied`. Restore it.
- **Out-of-order:** a prefix ≤ the highest applied version is rejected. Timestamps avoid this.
- Prefer backwards-compatible migrations. Breaking changes (rename/drop column) → expand-and-contract across multiple deploys. New table / nullable column → single migration is fine.

Bring-your-own migration system: pick a directory **other than** `netlify/database/migrations` to avoid automatic detection, and you own applying to preview branches and production.

See `references/migrations.md`.

## Local development

Local is **one** database that all code targets — branches are a deploy-time concept and don't exist locally. It's a real Postgres-compatible engine mirroring production, but single-process (not for load testing); auto-scale/sleep settings don't apply.

Start it — either path, state is interchangeable:
```bash
netlify dev                                    # CLI starts + tears down the local DB
```
Or the Vite plugin:
```ts title="vite.config.ts"
import { defineConfig } from "vite";
import netlify from "@netlify/vite-plugin";
export default defineConfig({ plugins: [netlify()] });
```

Common commands (while local DB is running):
```bash
netlify database migrations apply                        # apply pending locally
netlify database migrations new -d "add users table"     # scaffold new migration
netlify database migrations pull                          # overwrite local migrations from remote
netlify database status                                   # enabled? installed? applied/pending migrations
netlify database connect                                  # interactive SQL REPL
netlify database connect --query "SELECT * FROM users LIMIT 10"
netlify database reset                                    # drop all schemas/tables — LOCAL ONLY
netlify database migrations reset                         # delete unapplied local migration files
```

External tools (works while `netlify dev` runs):
```bash
psql "$(netlify database connect --json | jq -r .connection_string)"
```

See `references/local-dev.md`.

## Setup

New project: describe your app to Agent Runners at https://app.netlify.com/start, or `netlify create "<description>"` locally.

Existing project:
```bash
netlify database init      # installs @netlify/database, picks Drizzle or raw SQL, scaffolds a migration
netlify database init --yes # non-interactive (CI / agents)
netlify dev
```
Manual: `npm install @netlify/database`, write a migration under `netlify/database/migrations/`, write a function, `netlify dev`, deploy.

**If `@netlify/database` is NOT installed, Netlify will NOT auto-provision a database** — you'd have to create one manually from the UI **Data & Storage** > **Database** menu. Install the package.

## CLI reference (`netlify database`)

Prereqs: Node ≥ 20.12.2, Netlify CLI ≥ 26.0.0 (`npm install -g netlify-cli`). All commands support `--json`.

| Command | Purpose | Key flags |
|---------|---------|-----------|
| `init` | Set up DB in project | `-y, --yes` |
| `status` | State: enabled, installed, connection string, applied/pending migrations | `-b, --branch`, `--show-credentials` |
| `connect` | SQL REPL, or `--query` one-shot | `-q, --query`, `--json` |
| `migrations apply` | Apply pending to local DB | `--to <name>` |
| `migrations new` | Scaffold a migration | `-d, --description`, `-s, --scheme sequential\|timestamp` |
| `migrations pull` | Overwrite local files from a branch | `-b, --branch`, `--force` |
| `migrations reset` | Delete unapplied local migration files | `-b, --branch` |
| `reset` | Drop all data/tables — **local only** | — |

See `references/cli-commands.md`.

## REST API

Scoped to a site, rooted at `https://api.netlify.com/api/v1`, OAuth 2. Full reference: https://open-api.netlify.com.

| Method + path | Purpose |
|---------------|---------|
| `POST /sites/{site_id}/database` | Create DB (returns existing conn string if present); `region` optional |
| `GET /sites/{site_id}/database` | Get connection string |
| `POST /sites/{site_id}/database/branch` | Create branch; body `deploy_id` (req), `parent_branch_id` (opt, defaults to production) |
| `GET /sites/{site_id}/database/branch/{deploy_id}` | Get branch conn string (404 if none) |
| `DELETE /sites/{site_id}/database/branch/{deploy_id}` | Delete a deploy's branch |
| `POST /sites/{site_id}/database/snapshot` | Snapshot a branch (defaults production) |
| `GET /sites/{site_id}/database/snapshots` | List snapshots |
| `DELETE /sites/{site_id}/database/snapshot/{snapshot_id}` | Delete a snapshot |
| `POST /sites/{site_id}/database/snapshot/{snapshot_id}/restore` | Restore snapshot to a branch (defaults production) |

**Branch delete and snapshot restore are destructive and require explicit user confirmation first.** Snapshot restore is not a routine production-rollback lever.

## Testing

Bare Postgres for unit/integration tests (no functions):
```ts title="db.test.ts"
import { NetlifyDB } from "@netlify/database-dev";  // npm i -D @netlify/database-dev
import { Client } from "pg";
import { afterAll, beforeAll, expect, test } from "vitest";

let db: NetlifyDB, connectionString: string;
beforeAll(async () => {
  db = new NetlifyDB();
  connectionString = await db.start();
  await db.applyMigrations("./netlify/database/migrations");
});
afterAll(async () => { await db.stop(); });

test("inserts and reads a user", async () => {
  const client = new Client({ connectionString });
  await client.connect();
  await client.query("INSERT INTO users (name) VALUES ($1)", ["Ada"]);
  const { rows } = await client.query("SELECT name FROM users");
  expect(rows).toEqual([{ name: "Ada" }]);
  await client.end();
});
```
`NetlifyDB(options?)`: `directory` (persist to disk; omit = in-memory), `port` (default random), `logger`.

Full Netlify environment (functions/edge functions read `NETLIFY_DB_URL` as in production):
```ts
import { NetlifyDev } from "@netlify/dev";  // npm i -D @netlify/dev
const netlifyDev = new NetlifyDev({ projectRoot: "./fixtures/my-project" });
await netlifyDev.start();  // sets NETLIFY_DB_URL in the runtime
// ...tests...
await netlifyDev.stop();
```

## Database branches (deploy-time)

Production deploys are the only deploys that touch the production database. Each deploy preview gets its own branch, seeded with a copy of production data at preview-creation time; schema/data changes there never affect production. Wired up automatically, no code changes.

**Preview branches can contain production data, including PII — and preview deploy links are public. Warn the user before sharing a preview link.**

## Runtime gotchas

- **`Environment not configured`** (`getDatabase()` can't resolve a connection string): running outside Netlify, on **Functions in Lambda compatibility mode**, or an outdated CLI. Fix: pass `connectionString` explicitly.
  ```ts
  const db = getDatabase({ connectionString: "postgres://..." });
  ```
  Lambda compatibility mode is the one primitive where you must pass `connectionString` yourself.
- **`database feature not available for this account`** — requires a Credit-based plan.
- **`compute customization requires a Pro or higher plan`** — auto-scale / sleep settings need Pro+; Free/Personal use defaults.
- **`branch limit reached: maximum <N> branches...`** — each active deploy preview consumes a branch; delete unneeded branches or upgrade.
- **`database not found`** — no DB provisioned; run `netlify database init`.
- **`cannot reset the production branch`** — reset is non-production only.

## Constraints

- **Plan:** Netlify Database is available on Credit-based plans only; active DBs consume credits for compute and bandwidth. Storage is free until July 1, 2026.
- **Permissions:** only a Team Owner can delete a database; only Team Owners and Developers can view connection strings (`Access Denied` = insufficient role).
- **Secrets:** connection strings contain username + password. Never commit them; store in a secret manager / env var provider.

## Switch an existing Postgres project to Netlify Database

Three phases: provision (baseline schema on a branch), rehearse (swap code, copy data into a preview branch, validate), cut over (import data into production, merge). Works from any Postgres source (Neon, Supabase, RDS, self-managed, legacy `@netlify/neon`). Uses `pg_dump`/`pg_restore` (versions matching the source). There is a brief data-loss window — writes to the source between final export and production deploy don't cross over.

Phase 2/3 code swap (Drizzle):
```ts title="db/index.ts"
import { drizzle } from "drizzle-orm/netlify-db";
import * as schema from "./schema";
export const db = drizzle({ schema });
```

Full step-by-step (dump flags, rollback, cleanup): `references/migration-from-extension.md` and `references/legacy-extension.md`.

<!-- Gaps: plan-tier naming (Credit-based vs Free/Personal/Pro) not reconciled in source; exact plan limits, permission tables, and snapshot UI flows live on pages outside this grouping. -->

<!-- system: agent-context/database/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (database)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Production data changes are expressed as DML migrations — agents never
   edit rows directly (UI row editing exists for humans; it is not an agent
   surface).
2. Preview branches can contain production data, including PII — and preview
   deploy links are public. Warn before sharing.
3. Use only documented surfaces: no raw psql against internal endpoints, no
   `netlify api` scraping, no reading tokens from local CLI config files.
4. Deep guides live in this skill: `references/operational-footguns.md`,
   `references/migrations.md`, `references/local-dev.md`,
   `references/cli-commands.md`, `references/migration-from-extension.md`,
   `references/legacy-extension.md`.
5. Schema changes reach hosted databases only as committed migration files
   applied by the deploy. Never run `drizzle-kit push` in any form against a
   Netlify-hosted database, never run `drizzle-kit migrate` against
   `NETLIFY_DB_URL`, and never apply DDL via `netlify database connect` or
   any direct connection.
6. When a `netlify` command or a deploy fails, surface the exact error, the
   deploy log URL, and the affected site/branch to the user and stop — do
   not invent recovery commands or escalate to lower-level tools.
7. First-deploy `401 Access Denied` on `createSiteDatabase`: if it happened
   on a `--prod`-first deploy, retry preview-first (`netlify deploy`, no
   `--prod`); if a preview also fails, report and stop. Never curl
   `api.netlify.com`, run `netlify api createSiteDatabase`, or pull tokens
   from local CLI config to work around it.
8. A request to change existing data is ambiguous between production and the
   preview branch — if the prompt didn't say, ask. When acting on someone's
   behalf, default to not touching production.
9. Destructive database operations — REST branch delete, snapshot restore,
   any reset — require explicit user confirmation first. The body must not
   present snapshot restore as a routine production-rollback lever.
10. Pin: `drizzle-orm` and `drizzle-kit` must be installed from `@beta` —
    `latest` lacks the `drizzle-orm/netlify-db` adapter and will fail. The
    body may not soften this to a recommendation.

Referenced files: 6

netlify-deploy13.4 KB

View saved version →

---
name: netlify-deploy
description: Create, configure, and manage Netlify deploys from code — reach for this when setting up Git continuous deployment, running netlify deploy or netlify deploy --prod from the CLI, writing netlify.toml deploy contexts, adding a Deploy to Netlify button, wiring build hooks, configuring Deploy Previews or branch deploys, locking or skipping deploys, fixing a failed or secrets-scanning deploy, or when someone asks to "deploy my site", "set up preview deploys", "add per-branch build config", or "add a deploy button to my README".
---

# Netlify deploy

## Modern CLI

```bash
netlify deploy              # manual draft deploy (no CI)
netlify deploy --prod       # deploy straight to production
netlify create              # new project from a natural-language prompt
netlify deploy --allow-anonymous   # temp project, claim within 1 hour
npm update -g netlify-cli   # skew protection needs 23.11.0+
```

A deploy is a versioned, **atomic** snapshot: Netlify uploads only changed files and switches the live site only after all files land — the site is never in an inconsistent state. A deploy can be a preview or a production version served at your primary domain.

**Continuous deployment vs manual deploys:** Deploy with Git and the Netlify CLI support continuous deployment — a push auto-triggers a build. Drag and drop and the API create one-off manual deploys. Manual deploys (`netlify deploy`) do **not** run a build command; drag-and-drop while logged in is the only exception (framework auto-detected).

**⚠ When linking or creating a site, add `.netlify` to `.gitignore`.** Every linking path writes `.netlify/state.json`, which must not be committed.

## Ways to create a deploy

- **Git CD** — connect a repo; Netlify builds and deploys on every push (OAuth2 or the Netlify GitHub App). This is the default path.
- **CLI** — `netlify create`, `netlify deploy`, `netlify deploy --prod`.
- **Drag and drop** — https://app.netlify.com/drop. Logged in: builds if needed. Not logged in: publishes files as-is.
- **API** — create deploys via file digest or ZIP (one-off manual).
- **Deploy to Netlify button** — one-click from a public template repo.
- **Build hooks** — unique URLs that trigger builds. (Deploys from build hooks are treated as trusted and bypass the deploy request policy.)
- **AI agents** — Agent Runners (Claude Code, OpenAI Codex, Google Gemini) from the dashboard; every file-changing run auto-generates a Deploy Preview at `agent-<runID>--<site>.netlify.app`. The inline preview shown next to the prompt is the same Deploy Preview available at that URL.
- **Zapier / n8n** — automation integrations.

Not sure which path? The Deploy Navigator gives personalized recommendations: https://docs.netlify.com/start/choose-your-path#deploy-navigator (also embedded on the create-deploys page as "Not sure where to start?").

## netlify.toml deploy contexts

At the repo root. File config overrides UI settings. Five predefined contexts: `production`, `deploy-preview`, `branch-deploy`, `preview-server`, `dev`. Branch names also work as custom contexts; more specific contexts override general ones.

```toml
[context.production]
  command = "make production"
  [context.production.environment]
    ACCESS_TOKEN = "super secret"
  [[context.production.plugins]]        # plugins REQUIRE double brackets
    package = "@netlify/plugin-sitemap"

[context.deploy-preview.environment]
  ACCESS_TOKEN = "not so secret"

[context.branch-deploy]
  command = "make staging"

[context.dev.environment]
  NODE_ENV = "development"

[context."features/branch"]             # quote slashed branch names
  command = "gulp"
```

**⚠ Environment variables set in `netlify.toml` are NOT available to the deploy environment** — set them via UI/CLI/API. `netlify.toml` is committed, so keep sensitive values out of it; use per-context env vars via UI/CLI/API instead.

See `references/netlify-toml.md` for the full context precedence rules and `references/deployment-patterns.md` for context strategy.

## Deploy Previews & branch deploys

- **Deploy Previews** auto-build for PRs/MRs (GitHub, GitLab, Bitbucket, Azure DevOps, Cursor Origin) and agent runs. The base branch must be a production branch or a branch-deploy-enabled branch. URL: `deploy-preview-<num>--<site>.netlify.app`. While the first deploy is pending the URL returns `Not Found`.
- **Branch deploys** require setup: Project configuration > Developer settings > Continuous deployment > Branches and deploy contexts > Configure. Enable specific branches (prefix wildcard `features/*` supported) or **All** new branches. URL: `<branch>--<site>.netlify.app`.
- A branch-deploy branch with an open PR yields **both** a Deploy Preview and a branch deploy.
- **Entry path:** put `@netlify /some/path` in the PR/MR description, then push a new commit to regenerate. Once set in the PR, you can't change it in the Netlify Drawer.
- **Skip a deploy:** `[skip ci]` or `[skip netlify]` — in the PR/MR **title** to skip the Deploy Preview; **anywhere in the commit message** to skip a branch/production deploy. Next unmarked commit deploys all skipped changes.

## Locking, skipping, and manual production deploys

- **Lock** (disable auto publishing): Deploys list > **Lock to stop auto publishing**. New deploys still build but are not published. Unlock to resume.
- **⚠ Manual `netlify deploy --prod` on a Git-CD site:** the next push to the production branch silently replaces your hand-shipped deploy. Warn the user; lock the published deploy if it must stay live.

## Managing deploys

- **Find:** Deploys tab (Developer or Team Owner); search by deploy ID or branch name; filter by time frame, deploy context, and status.
- **Cancel:** on the in-progress deploy's detail page, **Cancel deploy** > **Yes, cancel deploy**.
- **Retry:** builds from the branch HEAD (optionally clearing cache) — if HEAD moved past the original deploy SHA, it still builds from HEAD.
- **Download:** on a successful deploy's detail page — a single file via **Deploy file browser**, or all files as a ZIP via the header **Download** > **Download ready**.
- **Delete:** Developer or Team Owner only. You cannot delete the deploy most recently published to the site's main URL, or one still in progress. Deletion is permanent and does not reduce team costs or preserve build minutes.

## Deploy to Netlify button

Template code must be in a **public** repo on **GitHub.com or GitLab.com**.

Markdown:
```md
[![Deploy to Netlify](https://www.netlify.com/img/deploy/button.svg)](https://app.netlify.com/start/deploy?repository=https://github.com/netlify/netlify-statuskit)
```

URL variants (base link `https://app.netlify.com/start/deploy`):
```txt
# require/pre-fill env vars (hash, client-side only; values may be null)
...?repository=<repo>#SECRET_TOKEN=specialuniquevalue&CUSTOM_LOGO=

# monorepo base dir (whole repo cloned, builds from blog/)
...?repository=<repo>&base=blog

# clone only a subdirectory
...?repository=<repo>&create_from_path=examples/hello

# deploy a specific branch (sets it as production branch)
...?repository=<repo>&branch=beta-feature

# install required SDK extensions before first deploy
...?repository=<repo>&fullConfiguration=true
```

File-based template config, `[template]` in the repo root `netlify.toml`:
```toml
[template]
  incoming-hooks = ["Contentful"]
  required-extensions = ["supabase"]

[template.environment]
  SECRET_TOKEN = "change me for your secret token"
  CUSTOM_LOGO = "set the url to your custom logo here"
```

You **cannot** set env var values or a base directory in `[template]` — use URL params. `[template.environment]` placeholder strings are only UI labels.

**⚠ Template configuration (incoming hooks, template env vars) is read ONLY from the repository ROOT.** When the button targets a subdirectory via `base`, the base-directory `netlify.toml` takes precedence for builds, but template config there is ignored. State this limitation explicitly rather than leaving it implied.

## Secrets scanning failures

**⚠ A secrets-scanning deploy failure means a value that looks like a secret reached your build output.** If it's a real secret, that's a leak — stop shipping it in client/published output and rotate it. **Never** set `SECRETS_SCAN_ENABLED=false` to silence the scanner over a real leak. For genuinely non-secret values, scope narrowly with `SECRETS_SCAN_OMIT_KEYS` / `SECRETS_SCAN_OMIT_PATHS`.

## Fixing a failed deploy — no rollbacks

**A failed deploy never publishes** — the previous deploy is still live, so there is nothing to restore. If someone asks to roll back or restore a previous deploy, correct the premise: after a failed deploy nothing changed, and for a bad *published* deploy, **fix forward** — revert the commit and let CI redeploy it. Do not call `restoreSiteDeploy` or `publishDeploy`, and do not hand over a dashboard rollback as the answer.

Netlify surfaces a **Why did it fail?** AI diagnosis above the deploy log — this diagnosis and its suggested solution do **NOT** consume credits. Selecting **Fix with agent** starts an agent run, which **DOES** consume credits from your team's balance. See https://docs.netlify.com/resources/troubleshooting/fix-a-failed-deploy/.

## Deploy permissions (private repos)

Netlify only builds changes pushed to private repos from **recognized authors** (Owners, Developers, Git Contributors; Marketplace bots count). An unrecognized author's merge shows **Pending approval**; a Team Owner must associate them with a team account before the build starts. Build-hook deploys are exempt.

## Constraints & gotchas

- **Files per directory: 54,000.** Any directory over this in the publish dir fails the deploy. No limit on total files per deploy.
- **Skew protection:** all plans; **production context only** — branch deploys, Deploy Previews, and permalinks bypass it and serve the latest deploy. Needs Netlify CLI 23.11.0+. Astro 5.15.0+ enables it by default via the Netlify Adapter; Next.js is opt-in. Password protection on production deploys (or on all deploys) turns skew protection off — it only works when you protect non-production deploys only. Netlify discards skew protection signals on hard navigation (`Sec-Fetch-Mode: navigate`, or `Sec-Fetch-Site` present and not `same-origin`). Framework maintainers add support via `netlify/v1/skew-protection.json`.
- **Search indexing:** only the published production deploy and most recent branch deploys are indexable; previews and old deploys get `X-Robots-Tag: noindex`.
- **Preview URL visibility:** Deploy Preview / branch deploy URLs are shareable with anyone holding the link unless you add password or team-login protection.
- **New-project visibility:** on Credit-based plans with "private by default", new projects start private regardless of how they're created.
- **Automatic deletion:** deploys are deleted after 30 days (90 days on paid plans); Enterprise can raise this up to 365 days. Never deleted: the published deploy, the most recent successful production deploy, and the most recent successful branch deploy per branch. Configure at Project configuration > Developer settings > Automatic Deletion.

See `references/cli-commands.md` for the full CLI surface and flags.

<!-- Retention period for failed/canceled deploys is stated inconsistently in sources (30/90 days vs 6 months); used the 30/90-day figure. -->

<!-- system: agent-context/deploy/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (deploy)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Agents do not roll back deploys: never call `restoreSiteDeploy` or
   `publishDeploy` to restore an older deploy. Fix forward — revert the
   commit and let CI deploy it.
2. A failed deploy never publishes; on failure there is nothing to roll
   back.
3. Deep guides live in this skill: `references/netlify-toml.md`,
   `references/cli-commands.md`, `references/deployment-patterns.md`.
4. The frontmatter description must never advertise rollback or restore as a
   capability — no "roll back", "restore a deploy", or equivalent.
5. When the user asks to roll back or restore a previous deploy, correct the
   premise rather than complying: after a failed deploy the previous deploy
   is still live and there is nothing to restore; for a bad published deploy,
   fix forward per rule 1. Do not hand over `restoreSiteDeploy` /
   `publishDeploy` or a dashboard rollback as the answer.
6. Always add `.netlify` to `.gitignore` when linking or creating a site —
   every linking path writes `.netlify/state.json`, which must not be
   committed. Mention it whenever you link.
7. Secrets-scanning deploy failures: if the flagged value is a real secret,
   that is a leak — stop shipping it in client/published output and rotate
   it; never silence the scanner over a real leak. For genuinely non-secret
   values, scope narrowly with `SECRETS_SCAN_OMIT_KEYS` /
   `SECRETS_SCAN_OMIT_PATHS`, never `SECRETS_SCAN_ENABLED=false`.
8. Before running a manual `netlify deploy --prod` on a site with Git CD
   connected, warn the user that the next push to the production branch
   silently replaces the hand-shipped deploy; suggest locking the published
   deploy if it must stay live.
9. Deploy-to-Netlify buttons: template configuration (incoming hooks,
   template env vars) is only read from the repository ROOT. When a
   button targets a subdirectory via `base`, state this limitation
   explicitly — do not leave it implied.

Referenced files: 3

netlify-edge-functions16.1 KB

View saved version →

---
name: netlify-edge-functions
description: Write and configure Netlify Edge Functions — TypeScript/JavaScript handlers running in a Deno runtime at the network edge. Use when adding auth middleware or auth redirects, geolocation or localization logic, A/B testing or personalization, request/response transforms (rewrites/redirects), or edge SSR to a Netlify site. Triggers on tasks like "add an edge function", "auth check at the edge", "redirect visitors by country", "A/B test with cookies", "rewrite requests", "personalize by geo", or "cache an edge response". Covers the config export, path routing, the Context object, response caching, environment variables, and edge-vs-serverless choices. Check the framework's adapter first — only hand-write an edge function when the framework doesn't already generate one.
---

# Netlify Edge Functions

## Modern syntax (reach for this)

Export a default handler plus a `config` object. Import `Config`/`Context` types from `@netlify/edge-functions`; `Request`/`Response`/`URL` are global.

```ts
import type { Config, Context } from "@netlify/edge-functions";

export default async (request: Request, context: Context) => {
  return new Response("Hello world");
};

export const config: Config = {
  path: "/test",
};
```

**Do not hand-write an edge function when your framework's adapter already generates middleware for the job** — duplicating it causes conflicts. Check the framework adapter/reference first.

**Edge vs serverless:** use edge functions for low-latency request/response manipulation, geolocation logic, auth checks/redirects, and A/B personalization. Use serverless functions for long-running work (up to 15 min), heavy Node.js dependencies, database-heavy operations, background/scheduled tasks, or memory above 512 MB.

## File location

- Default directory: `YOUR_BASE_DIRECTORY/netlify/edge-functions`. Custom: `edge_functions` under `[build]` in `netlify.toml` (path relative to base directory).
- Keep the directory **outside your publish directory** so source files aren't deployed.
- Extensions: `.js`, `.ts`, `.jsx`, `.tsx` (`.jsx`/`.tsx` useful for SSR).
- Same-name conflict: if `my-function.ts` and `my-function.js` both exist, the **TypeScript file is ignored** and the JavaScript one is deployed.

## Routing — required, or the function silently never runs

⚠️ **An edge function without a route (no `config` export and no `netlify.toml` declaration) still deploys but never runs — no build error, no warning.** When "my edge function does nothing", check the route first.

⚠️ **Scope `path` narrowly.** `path: "/*"` intercepts every request including static assets, adding latency and billing an edge invocation for each one.

Edge functions are **not** auto-assigned a URL route. Configure via inline `config` or `netlify.toml`.

`path` is a `URLPattern` expression, must start with `/`, single string or array:

```ts
export const config: Config = {
  path: ["/", "/products/*"],
  excludedPath: ["/*.css", "/*.js"],
};
```

Config properties: `path`, `excludedPath`, `pattern` (regex alternative to `path`), `excludedPattern`, `method`, `header`, `onError`, `cache`.

### netlify.toml declaration

Use `[[edge_functions]]` to declare multiple functions on one path and control order:

```toml
[[edge_functions]]
  path = "/admin"
  function = "auth"

[[edge_functions]]
  path = "/admin"
  function = "injector"
  cache = "manual"

[[edge_functions]]
  pattern = "/products/(.*)"
  excludedPattern = "/products/things/(.*)"
  function = "highlight"
```

Properties: `function`, `path`, `excludedPath`, `pattern`, `excludedPattern`, `header`, `cache`.

**Merge precedence:** if the same function is declared both inline and in `netlify.toml`, configs merge and are treated as inline; inline wins duplicate fields.

### Match by headers

`header` keys are HTTP header names (case-insensitive); values are `true` (present), `false` (absent), or a string regex on the value. Multiple same-name values match against the comma-joined list.

```ts
export const config: Config = {
  header: { "x-required": true, "x-forbidden": false, "user-agent": "(iPhone|Android)" },
  path: "/*",
};
```

### Declaration processing order

Netlify runs the whole declaration order **TWICE**: the first pass runs only edge functions **not** configured for caching; the second pass runs the ones **with** caching configured. Within that:

1. Framework-generated functions declared in a config file.
2. Your `netlify.toml` declarations (top-to-bottom order).
3. Framework/integration-generated functions with inline config.
4. Your inline declarations (**alphabetical by function file name**).

To control order across multiple functions on a path, prefer `netlify.toml` declarations over inline.

After all functions run, Netlify evaluates redirect rules — unless a function returned a response and ended the chain. To customize order, use `netlify.toml`.

**Order caveats:**
- A returned response ends the chain; redirects for that path don't occur.
- An edge function on the **target** of a static rewrite does **not** execute for rewritten requests.
- `fetch()` for internal requests or returning a `URL` starts a **new request chain** and re-runs matching edge functions. Use `context.next()` to avoid re-running them.

## Function signature & return values

Handler receives `(request: Request, context: Context)`. Return one of:
- a `Response` — delivered to the client; **ends the request chain** (declared redirects for that path don't run).
- a `URL` — rewrite to a **same-site** URL with 200 status; address bar unchanged. Same-site only — for other sites use `fetch`.
- `undefined` / empty `return;` — bypass this function, continue the chain.

Modify a response as middleware by awaiting `context.next()`:

```ts
import type { Context } from "@netlify/edge-functions";

export default async (request: Request, context: Context) => {
  const url = new URL(request.url);
  if (url.searchParams.get("method") !== "transform") return;

  const response = await context.next();
  const text = await response.text();
  return new Response(text.toUpperCase(), response);
};
```

Netlify does **not** add headers to edge function requests — use `context` for client request info.

## Common patterns

**Redirect by geo + cookie:**
```ts
export default async (req: Request, { cookies, geo }: Context) => {
  if (geo.city === "Paris" && cookies.get("promo-code") === "15-for-followers") {
    return Response.redirect(new URL("/subscriber-sale", req.url));
  }
};
```

**Rewrite (same-site, 200):**
```ts
export default async (request: Request, { geo }: Context) => {
  if (geo.city === "Paris") return new URL("/subscriber-sale", request.url);
};
```

**Read request body then continue** — a body can only be read once, so pass a new `Request` with an unread body:
```ts
export default async (req: Request, context: Context) => {
  const body = await req.json();
  if (!isValid(body.access_token)) return new Response("forbidden", { status: 403 });
  return context.next(new Request(req, { body: JSON.stringify(body) }));
};
```

**Conditional request:**
```ts
export default async (req: Request, { next }: Context) => {
  const res = await next({ sendConditionalRequest: true });
  if (res.status === 304) return res;
  const text = await res.text();
  return new Response(text.toUpperCase(), res);
};
```

**SSR with React (`.tsx`):**
```tsx
import React from "https://esm.sh/react";
import { renderToReadableStream } from "https://esm.sh/react-dom/server";
import type { Config, Context } from "@netlify/edge-functions";

export default async function handler(req: Request, context: Context) {
  const stream = await renderToReadableStream(
    <html><body><h1>Hello {context.geo.country?.name}</h1></body></html>
  );
  return new Response(stream, { status: 200, headers: { "Content-Type": "text/html" } });
}

export const config: Config = { path: "/hello" };
```

## Context object

- **`geo`** — `city`, `country.{code,name}`, `subdivision.{code,name}`, `latitude`, `longitude`, `timezone`, `postalCode`.
- **`cookies`** — `get(name)`, `set(options)` (CookieStore.set format), `delete(name|options)`. Cross-subdomain cookies need a custom domain — impossible on `netlify.app` (Public Suffix List).
- **`next(options?)`** / **`next(request, options?)`** — invoke the next item in the chain; returns a `Promise<Response>` you can modify. `options.sendConditionalRequest: true` for conditional requests. Only call `next` if you need the response body. Pass an explicit `Request` when you've read the body.
- **`params`** — path params, e.g. path `/pets/:name` + request `/pets/winter` → `{name:"winter"}`. Query string: use `request.url`.
- **`ip`** — client IP string.
- **`requestId`** — Netlify request ID.
- **`account.id`**, **`site.{id,name,url}`**, **`server.region`**, **`deploy.{context,id,published,skewProtectionToken}`**.
- **`waitUntil(promise)`** — extend execution past the response (analytics, logs) without blocking it. Still subject to the CPU limit.

**`Netlify` global:** `Netlify.context` (null outside the handler), `Netlify.env.{get,has,set,delete,toObject}`. `Netlify.env.set`/`delete` are **invocation-scoped only** — they do not persist env vars; use the Netlify env API endpoints.

## Response caching

⚠️ **Caching requires BOTH opting in AND setting headers — it's both or neither.** Setting `Cache-Control` on the returned `Response` does nothing without `cache: "manual"` in config, and vice versa. Default (either missing): every request invokes the function.

1. Opt in: `cache: "manual"` (inline or `netlify.toml`).
2. Set headers **inline in the function code** (not in `netlify.toml`):

```ts
import type { Context, Config } from "@netlify/edge-functions";

export default async (req: Request, context: Context) => {
  return new Response("Hello world", {
    headers: { "cache-control": "public, s-maxage=3600" },
  });
};

export const config: Config = { cache: "manual", path: "/hello" };
```

Supported cache headers: `Cache-Control`, `CDN-Cache-Control`, `Netlify-CDN-Cache-Control`, `Expires` (overridden by `max-age`/`s-maxage`), `Vary`, `Netlify-Vary`. See https://docs.netlify.com/build/caching/caching-overview

**Atomic deploys void the cache:** `s-maxage`/`max-age`/`Expires` are discarded by a new deploy in the same deploy context, even mid-lifetime.

**When to cache:** endpoint responses reusable across clients (e.g. identical SSR HTML). **Do not cache** middleware, routing/transform logic, or per-client personalization.

⚠️ **Caching functions always shadow static files.** A caching function on `/*` serves `/cat.png` instead of the static `cat.png`.

## Error handling (`onError`, inline only)

- **`fail`** (default) — serve a generic error page.
- **`/YOUR_CUSTOM_PATH`** — rewrite to a same-site path (must start with `/`); served without invoking edge functions for that path.
- **`bypass`** — skip the erroring function, continue the chain.

```ts
export const config: Config = { path: "/hello", onError: "/unavailable" };
```

Fail closed for critical logic (auth); fail open (`bypass`) for progressive enhancement (nice-to-have localization).

## Environment variables

- Set via UI/CLI/API; scope **must include Functions** to reach edge runtime.
- **Env vars in `netlify.toml` are NOT available to edge functions.**
- **Build-scope vars are NOT available at edge runtime** — only during the build step. Embed their values at build time if needed.
- Changes require a **new build and deploy**; each deploy freezes values at deploy time.
- Access at runtime with `Netlify.env.get(key)` / `Netlify.env.toObject()`.

```ts
export default async (request: Request, context: Context) => {
  const value = Netlify.env.get("MY_IMPORTANT_VARIABLE");
  return new Response(`Value: ${value}`);
};
```

Next.js Middleware note: with Netlify Edge Functions for Middleware on Next.js, `process.env` also works.

## Runtime & modules

Deno-based. Import modules by:
- **Node built-ins:** `import { randomBytes } from "node:crypto";`
- **Deno/URL imports:** `import React from "https://esm.sh/react";`
- **npm packages (beta):** `npm install` then import by name. ⚠️ Beta — packages using native binaries (Prisma) or runtime dynamic imports (cowsay) may fail.

**Import maps** (module names instead of URLs) — use a separate import map file, declared in `netlify.toml`:

```toml
[functions]
  deno_import_map = "./path/to/your/import_map.json"
```

Supported Web APIs include `fetch`/`Request`/`Response`/`URL`/`File`/`Blob`, `console`, `atob`/`btoa`, `TextEncoder`/`TextDecoder` (+ stream variants), Web Crypto (`randomUUID`, `getRandomValues`, `SubtleCrypto`), WebSocket, timers, Streams API, URLPattern, `Performance`.

## Local dev & deploy

```bash
npm install netlify-cli -g
netlify dev        # runs edge functions on local requests
# visit http://localhost:8888/test
```

- Debug: `netlify dev` with `--edge-inspect` or `--edge-inspect-brk` (see https://cli.netlify.com/commands/dev/).
- Geo mocking: `--geo=mock` (San Francisco) or `--geo=mock --country=XX`.
- ⚠️ **No local caching** — cache headers are ignored in local testing.
- Manual deploys require **Netlify CLI 12.2.8+** (older versions error).
- Deploys are **atomic** — old deploys keep old behavior until you publish a new production deploy.

**Monitor:** production logs at Netlify UI **Cloud compute > Edge functions**. Each `console.*` log includes the generating function name. Retention ≥ 24h (7 days on some plans). Log Drains on Enterprise.

## Limits & feature gaps

- **Code size:** 20 MB compressed (bundle max).
- **Memory:** 512 MB per set of deployed edge functions.
- **CPU time:** 50 ms per request (excludes wait time; `waitUntil` work still counts).
- **Response header timeout:** 40 s.
- Cached responses do **not** count toward invocations.
- **Split Testing** enabled → edge functions do **not** run.
- **Custom Headers** (incl. basic auth) do **not** apply to edge functions.
- **Prerendering** does not apply to edge-served paths.
- Rewrites are **same-site only** — use `fetch` for other/external sites.
- Multiple framework plugins generating edge functions may collide.
- **Not** supported under HIPAA-compliant hosting.

See the overview at https://docs.netlify.com/build/edge-functions/overview.md and the full example library at https://edge-functions-examples.netlify.app/

<!-- system: agent-context/edge-functions/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (edge-functions)

These are org conventions and field-learned guardrails, not docs facts — they
are merged into the rendered skill by ctx-gen and are never generated.
Extracted from the previous hand-written netlify-edge-functions skill; owned
by the skills maintainer.

1. Check the framework's adapter/reference first: a custom edge function that
   duplicates adapter-generated middleware causes conflicts. Only hand-write
   an edge function when the framework doesn't already generate one for the
   job.
2. Scope `path` narrowly. `path: "/*"` intercepts every request — including
   static assets — adding latency to each one and billing an edge invocation
   for it.
3. An edge function without a route (no config export, no netlify.toml
   declaration) still deploys, but silently never runs: no build error, no
   warning. When "my edge function does nothing", check the route first.
4. Choose edge vs serverless by workload shape: edge functions for low-latency
   request/response manipulation, geolocation logic, auth checks/redirects,
   and A/B personalization; serverless functions for long-running work (up to
   15 min), heavy Node.js dependencies, database-heavy operations,
   background/scheduled tasks, or memory needs above 512 MB.
5. Cache headers on an edge response do nothing without `cache: "manual"` in
   config — it's both or neither. Setting `Cache-Control` on the returned
   `Response` has no effect unless the function also opts in.
6. When explaining declaration processing order, state the two-pass loop,
   not just the ordering: Netlify runs the whole declaration order TWICE —
   first pass runs only edge functions not configured for caching, second
   pass runs the ones with caching configured. "Non-cached before cached"
   without the loop framing is an incomplete answer.
netlify-forms11.2 KB

View saved version →

---
name: netlify-forms
description: Serverless form handling on Netlify-hosted sites — detects HTML forms at deploy time, stores submissions, filters spam, and sends notifications. Use when adding a contact form, lead-capture form, file-upload form, or newsletter signup to a Netlify site; wiring AJAX form submission; setting up a custom thank-you page; adding a honeypot or reCAPTCHA to a form; getting forms working in Next.js, Nuxt, SvelteKit, Astro, or Gatsby; reading form submissions via the Netlify API; or debugging missing submissions and forms that silently fail to register.
---

# Netlify Forms

Mark a form for detection with `data-netlify="true"` (or the bare `netlify` attribute — equivalent) on the `<form>` tag. Forms are detected by **parsing the final built HTML at deploy time** — there is no runtime API call or backend code. Client-side/JS-rendered/SSR forms are NOT in the built HTML and are never detected on their own; they require a static skeleton file (see below).

Prerequisite: form detection must be enabled once in the Netlify UI (Forms > **Enable form detection**). Takes effect on the next deploy.

## Static HTML form

```html
<form name="contact" method="POST" data-netlify="true">
  <p><label>Your Name: <input type="text" name="name" /></label></p>
  <p><label>Your Email: <input type="email" name="email" /></label></p>
  <p><label>Message: <textarea name="message"></textarea></label></p>
  <p><button type="submit">Send</button></p>
</form>
```

- `name` sets the form name in the UI and **must be unique per site**.
- At deploy, Netlify strips the `data-netlify`/`netlify` attribute and injects `<input type="hidden" name="form-name" value="contact" />`.
- Add an `<input name="email">` so the notification email's `Reply-to` is set to the submitter.

## JS-rendered / SSR / framework forms (Next.js, Nuxt, SvelteKit, Astro, Gatsby)

Two required pieces:

**1. Static skeleton file `public/__forms.html`** — a hidden copy of each form with `data-netlify="true"`, a hidden `form-name` input, and every field the component submits, with names matching **exactly** (Netlify validates field names against the registered form). Without this file, submissions silently fail.

```html
<!-- public/__forms.html -->
<form name="pizzaOrder" data-netlify="true" hidden>
  <input type="hidden" name="form-name" value="pizzaOrder" />
  <input name="order" type="text" />
</form>
```

**2. The rendered form** carries a matching hidden `form-name` input:

```jsx
<form name="pizzaOrder" method="post" data-netlify="true" onSubmit={handleSubmit}>
  <input type="hidden" name="form-name" value="pizzaOrder" />
  <input name="order" type="text" onChange={handleChange} />
  <input type="submit" />
</form>
```

**⚠️ SSR POST target:** In SSR apps, `fetch("/")` is intercepted by the SSR catch-all function and never reaches form processing. POST to the static skeleton file itself — `/__forms.html` — not `/` or an arbitrary path.

**⚠️ Astro on-demand routes:** Routes with `export const prerender = false` or `output: "server"` are never scanned at build time, so their forms are never registered. Put the form on a prerendered page, or rely on the static skeleton file.

**Next.js Runtime v5 (Next.js 13.5+):** extract form definitions to the static skeleton file and submit via AJAX rather than full-page navigation. See https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview#v5-breaking-changes

## AJAX submission

```js
const handleSubmit = event => {
  event.preventDefault();
  const formData = new FormData(event.target);
  fetch("/__forms.html", {   // static sites may POST to "/"; SSR must target the skeleton file
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams(formData).toString()
  })
    .then(() => alert("Thank you for your submission"))  // or navigate("/thank-you")
    .catch(error => alert(error));
};
document.querySelector("form").addEventListener("submit", handleSubmit);
```

- **Body MUST be URL-encoded. JSON is NOT supported.**
- If the rendered form has no hidden `form-name` input, you MUST include a `form-name` field in the POST body.
- The honeypot field name and `g-recaptcha-response` (if used) must be in the body — automatic with `FormData()`.

## File uploads

Add `type="file"`; optionally `enctype="multipart/form-data"` on the `<form>`. For AJAX file uploads, **do NOT set a `Content-Type` header** — let the browser set it (with the multipart boundary).

```js
document.forms.fileForm.addEventListener("submit", event => {
  event.preventDefault();
  fetch("/", { body: new FormData(event.target), method: "POST" })  // no headers
    .then(() => { /* success */ });
});
```

Limits: one file per field (use multiple fields for multiple files) · 8 MB max request size · 30 s upload timeout · after form deletion, uploaded files stay at their direct URL for 24 h. PII uploads need extra security (Very Good Security integration).

## Custom success page

Add an `action` path relative to site root, starting with `/`. **Use extensionless paths** — Netlify serves `thank-you.html` at `/thank-you`; the `.html` path returns 404.

```html
<form name="contact" action="/thank-you" method="POST" data-netlify="true"></form>
```

Custom success *alert* is only possible via AJAX (substitute the redirect with your own logic).

## Spam prevention

All submissions are filtered by Akismet. Passed → **Verified submissions**; flagged → **Spam submissions**. Honeypot/reCAPTCHA failures are rejected and appear in neither list.

**Honeypot:** add `netlify-honeypot="bot-field"` to the `<form>` and include a CSS-hidden field of that name. Any value entered → submission quietly rejected.

```html
<form name="contact" method="POST" netlify-honeypot="bot-field" data-netlify="true">
  <p class="hidden"><label>Don’t fill this out: <input name="bot-field" /></label></p>
  <!-- real fields -->
</form>
```

**Netlify reCAPTCHA 2:** add `data-netlify-recaptcha="true"` to the `<form>` AND an empty `<div data-netlify-recaptcha="true"></div>` where it renders. Only ONE Netlify-provided challenge per page — for multiple, use custom reCAPTCHA. For JS-rendered forms, also add the `div` to the static skeleton file.

**Custom reCAPTCHA 2:** your own reCAPTCHA snippet + `data-netlify-recaptcha="true"` on the `<form>`, plus env vars:
- `SITE_RECAPTCHA_KEY` — site key (scopes: Builds + Runtime)
- `SITE_RECAPTCHA_SECRET` — secret (scope: Runtime)

## Email notifications & subject line

Default sender: `formresponses@netlify.com`. Set subject via a hidden `subject` input **or** the Netlify UI (Forms > Submission notifications) — **not both; the HTML value always overrides the UI.**

```html
<input type="hidden" name="subject" value="New lead from %{formName} (%{submissionId})" />
```

Variables: `%{formName}`, `%{siteName}`, `%{submissionId}`. Forms created before **May 5, 2023** carry a `[Netlify]` subject prefix — remove it by adding the `data-remove-prefix` attribute to the `subject` input.

Set up notifications (email/webhook/Slack) in the UI: Forms > Submission notifications > **Add notification**.

## Reading submissions via the API

Use only documented surfaces. Do NOT invent `api.netlify.com` endpoints or read tokens from local CLI config files. Reference: https://open-api.netlify.com/#tag/submission/operation/listFormSubmissions

- **Page through results using the `Link` header** — code that reads only the first response silently drops the rest.
- `listFormSubmissions` returns data from old/removed fields no longer shown in the UI.
- Query spam with `?state=spam`.

## Submission summary (field order matters)

The UI summary is derived from field **type**, not name:
- **Title**: first non-hidden text `<input>` that isn't email-like (`type="email"`, or name matching `email`/`mail`/`from`/`twitter`/`sender`); falls back to a field named `title` or `subject`.
- **Body**: first `<textarea>`.

Field order in the HTML affects what appears in the summary.

## Debugging missing submissions

- **First suspect: Akismet false positive.** A missing legitimate submission is usually spam-flagged — check the **Spam** list (or API `?state=spam`) and mark it verified. Do NOT build a custom recovery function or disable spam filtering as a first resort.
- Test submissions get flagged as spam: use a real email (not `test@test.com`), write full sentences, don't hammer from one IP.
- No submissions at all: confirm form detection is enabled (Forms > Form detection) and redeploy.
- SSR/JS forms silently failing: verify the static skeleton file exists with exactly-matching field names and that AJAX targets the skeleton file, not `/`.
- Missing old-field data: the UI shows only fields from the last deployed form version. Mark old fields `hidden` instead of removing them to keep them visible; old data remains available via `listFormSubmissions`.

## Constraints

- Deleting a form is permanent: future submissions return `404`, past submissions become unavailable. Export CSV first.
- Submitted code is sanitized (`<script>` → escaped entities).
- For PII, export and delete data regularly.
- Data is stored in Netlify's database, not accessible except via UI/API/CSV.

<!-- Forms usage now at Forms > Usage; form detection at Forms > Form detection — UI paths updated per manifest commit a28cd46. -->

<!-- system: agent-context/forms/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (forms)

These are org conventions and field-learned guardrails, not docs facts — they
are merged into the rendered skill by ctx-gen and are never generated.
Extracted from the previous hand-written netlify-forms skill; owned by the
skills maintainer.

1. In SSR apps (Next.js, Nuxt, SvelteKit, etc.), `fetch("/")` is intercepted
   by the SSR catch-all function and never reaches Netlify's form processing.
   POST the AJAX submission to the static skeleton file itself (e.g.
   `/__forms.html`), not to an arbitrary path.
2. Use only documented surfaces: do not curl `https://api.netlify.com/...`
   with an invented endpoint shape, and do not read tokens out of local CLI
   config files (`~/Library/Preferences/netlify/config.json`).
3. When reading submissions via the API, page through results (`Link`
   header); code that reads only the first response silently drops the rest.
4. For JS-rendered and SSR forms, always create the static skeleton file
   `public/__forms.html`: a hidden copy of each form with
   `data-netlify="true"`, a hidden `form-name` input, and every field the
   component submits — names matching exactly (Netlify validates field names
   against the registered form). Without this file, submissions silently fail.
5. Astro routes rendered on demand (`export const prerender = false`, or
   `output: "server"` routes) are never scanned at build time, so their forms
   are never registered. Put the form on a prerendered page or rely on the
   static skeleton file.
6. A "missing" legitimate submission is usually an Akismet false positive:
   check the Spam list (or the API with `?state=spam`) and mark it verified.
   Do not build a custom recovery function or disable spam filtering as a
   first resort.
7. For custom success pages, use extensionless `action` paths (`/thank-you`,
   not `/thank-you.html`) — Netlify serves `thank-you.html` at `/thank-you`
   and the `.html` path returns 404.
netlify-frameworks14.4 KB

View saved version →

---
name: netlify-frameworks
description: Deploy and configure web frameworks on Netlify — build settings and SSR/edge adapters plus local platform emulation and env vars. Use when setting up or fixing a framework deploy (Next.js / Astro / Nuxt / SvelteKit / Remix / React Router / TanStack Start / SolidStart / Gatsby / Angular / Vite / Express / Hydrogen / Hugo / Eleventy / Vue / React), adding SSR or edge functions or middleware wired to Netlify context, fixing SPA redirect and catch-all rules, setting a build command or publish directory, or debugging "why isn't my env var updating" and framework build failures.
---

Route framework-specific deep work to the guides in this skill: `references/astro.md`, `references/nextjs.md`, `references/nuxt.md`, `references/sveltekit.md`, `references/tanstack.md`, `references/vite.md`.

## Env vars: modern rules (read first)

Env values are injected **at build time**. Any change (client- or server-side) requires a **redeploy** — editing a var in the UI/CLI does NOT reach the live site or already-deployed functions until a new build runs.

**Never use a client prefix for secrets.** Client-prefixed vars are inlined into the browser bundle:
`VITE_`, `NEXT_PUBLIC_`, `PUBLIC_`, `NUXT_PUBLIC_`, `REACT_APP_`, `GATSBY_`, `VUE_APP_`.

Client-embed prefixes by framework: CRA `REACT_APP_`, Gatsby `GATSBY_`, Next `NEXT_PUBLIC_`, Nuxt `NUXT_ENV_`, Vue CLI `VUE_APP_`.

**Scopes:** build-time access needs **Builds** scope; SSR/DSG runtime access needs **both Functions and Builds**. `netlify.toml` is read only during build — functions cannot read it at runtime; set runtime vars in UI/CLI/API.

Netlify build variables can't be used as values in the UI or `netlify.toml` env sections. Set them inline before the build command:
```toml
[build]
  command = "REACT_APP_CONTEXT=$CONTEXT npm run build"
```

## SPA redirects and the SSR catch-all footgun

SPAs (React, Vue CLI, Vite, Nuxt in SPA mode) need a rewrite to serve `index.html` for `pushState`:
```
/* /index.html 200
```

**Remove any SPA catch-all when adopting an SSR adapter.** A leftover `/* → /index.html 200` silently serves static `index.html` for SSR pages and API routes — user redirects beat adapter-generated routes.

## Local dev with platform emulation (no Netlify CLI)

Vite-based frameworks emulate Netlify primitives (functions, edge functions, blobs, Netlify Database, Cache API, Image CDN, redirects/rewrites, headers, env vars, AI Gateway) in the dev server:

| Framework | Plugin/module | Run |
|-----------|---------------|-----|
| Astro (5.12+) | built-in (Netlify Vite plugin auto-loaded) | `astro dev` |
| Nuxt | `@netlify/nuxt` | `nuxt dev` |
| React Router | `@netlify/vite-plugin` | `react-router dev` |
| SolidStart 2 | `@netlify/vite-plugin` | `vite dev` |
| TanStack Start | `@netlify/vite-plugin-tanstack-start` | (vite) |
| Vite | `@netlify/vite-plugin` | `npx vite` |

Still need `netlify dev` (Netlify CLI) for: Gatsby generated functions (run `netlify build` first), Angular SSR local test (`netlify serve`), and frameworks without a Vite plugin.

**`netlify dev` gotcha:** with both a custom `command` and a `targetPort` in `[dev]`, you must set `framework = "#custom"` — otherwise the detector runs and your custom command is silently ignored.

## Build settings by framework

| Framework | Build command | Publish |
|-----------|---------------|---------|
| Angular (standard) | `ng build --prod` | `dist/YOUR_PROJECT_NAME` |
| Astro | `astro build` | `dist` |
| Create React App | `react-scripts build` | `build` |
| Eleventy | `eleventy` | `_site` |
| Gatsby | `gatsby build` | `public` |
| Hugo | `hugo` | `public` |
| Hydrogen | `remix vite:build` | `dist/client` |
| Next.js (SSR/hybrid) | `next build` | `.next` |
| Next.js (static export) | `next build && next export` | `out` (`NETLIFY_NEXT_PLUGIN_SKIP=true`) |
| Nuxt 3 | `nuxt build` | `dist` |
| Nuxt 2 | `nuxt generate` | `dist` |
| React Router | `react-router build` | `build/client` |
| Remix (Vite) | `remix vite:build` | `build/client` |
| SolidStart 2 (Vite plugin) | `vite build` | `dist/client` |
| SolidStart 2 (Nitro) | `vite build` | `dist` |
| SolidStart 1.x | `vinxi build` | `dist` |
| SvelteKit | `vite build` | `build` |
| TanStack Start (1.132.0+) | `vite build` | `dist/client` |
| Vite | `vite build` | `dist` |
| Vue CLI | `vue-cli-service build` | `dist` |

Detection suggests these; override in `netlify.toml` or UI (project configuration > Build & deploy > Continuous deployment > Build settings).

## SSR / adapter setup

### Astro
`npx astro add netlify` installs the adapter and edits `astro.config.mjs`. Adapter needed for SSR and out-of-the-box Image CDN for `<Image />`. SSR → Netlify Functions; middleware → Edge Functions. Adapter-less deploy only if no server features and no Image CDN need. Skew protection from 5.15.0.

### Next.js (13.5+ only)
Zero-config via the OpenNext adapter (`@netlify/plugin-nextjs`). Do NOT pin the version — Netlify auto-updates each build. Treat the legacy adapter as read-only history, never a recommendation.
Adapter provisions: serverless function for SSR/ISR/PPR/route handlers/Server Actions; Edge Function for Middleware; Full Route + Data Cache; Image CDN with `next/image`.
Skew protection is opt-in: set `NETLIFY_NEXT_SKEW_PROTECTION=true`, redeploy. No automatic support for client `fetch` — direct calls with `x-deployment-id: process.env.NEXT_DEPLOYMENT_ID`. Details in `references/nextjs.md`.

### SvelteKit
```bash
npm install -D @sveltejs/adapter-netlify
```
```js
import adapter from '@sveltejs/adapter-netlify';
export default { kit: { adapter: adapter() } };
```
Replace `@sveltejs/adapter-auto` with the specific import. SSR routes → a `render` function.
- `split: true` → one function per route. **Incompatible with Edge Functions** (`edge: false` or omit).
- `edge: true` → SSR in a Deno edge function; can't combine with `split`.
- **Redirects NOT supported in `netlify.toml`** — use `_redirects`.
- Edge functions don't work locally with `netlify dev` for SvelteKit.

### React Router (7+)
New: `npx create-react-router@latest --template netlify/react-router-template`. Existing:
```bash
npm install @netlify/vite-plugin-react-router
```
Add `netlifyReactRouter()` to Vite plugins. Default target = Serverless Functions.
**Edge (Deno):** needs plugin v2.1.1+, set `edge: true`, and you **must** create `app/entry.server.tsx`:
```typescript
export { default } from 'virtual:netlify-server-entry'
```
Exclude your own function paths: `netlifyReactRouter({ edge: true, excludedPaths: ['/api/*'] })`.
**Moving back to Serverless:** remove `edge: true` AND delete `app/entry.server.tsx`.
Middleware (React Router v7.9.0+, plugin v2.0.0+): opt in via `future.v8_middleware`; import `netlifyRouterContext` from `@netlify/vite-plugin-react-router/serverless` (or `/edge` when `edge: true`); access `context.get(netlifyRouterContext)`.

### Remix
New: `npx create-remix@latest --template netlify/remix-template` (CLI prompts functions vs Edge Functions). Manual (Remix Vite required):
```bash
npm install --save-dev @netlify/remix-adapter
```
Add `netlifyPlugin()` from `@netlify/remix-adapter/plugin` to Vite plugins.

### Nuxt
SSR via Nitro, automatic on Nuxt 3. Local parity via `@netlify/nuxt` (`npx nuxi module add @netlify/nuxt`).
- SSR on Edge Functions requires a different Nitro deployment preset (not auto-detected).
- pnpm + Nuxt 3: set `PNPM_FLAGS=--shamefully-hoist`.
- `nuxt/image` auto-uses Netlify Image CDN; set remote domains in `nuxt.config.ts`.

### SolidStart
SolidStart 2 builds on Vite — **no SolidStart-specific adapter**. Install `@netlify/vite-plugin`:
```ts
import netlify from "@netlify/vite-plugin";
import { solidStart } from "@solidjs/start/config";
import { defineConfig } from "vite";
export default defineConfig({
  plugins: [solidStart(), netlify({ build: { enabled: true } })],
});
```
Publish `dist/client`. SSR routes, server functions, middleware → Netlify Functions, zero extra config.
**Nitro alternative:** add `nitro()`, use plain `netlify()` (no `build.enabled`), publish `dist`.
SolidStart 1: Nitro auto-configures; optionally set `preset: "netlify"` in `app.config.ts`; `vinxi build` / `dist`.

### TanStack Start
React (and Solid.js) full-stack; SSR/Server Routes/Server Functions/middleware → serverless functions.
```bash
npm install -D @netlify/vite-plugin-tanstack-start
```
Add `netlify()` to Vite plugins alongside `tanstackStart()`; `vite build` / `dist/client` (1.132.0+). Netlify CLI deploys require netlify-cli 17.31+. Older versions: see `references/tanstack.md`.

### Gatsby
- **5.12.0+ (adapter):** auto-detects and installs `gatsby-adapter-netlify` (zero-config). Generates functions `SSR`, `DSG`. No Essential Gatsby plugin needed.
- **5.11.0 or earlier (Essential Gatsby plugin):** auto-installs `@netlify/plugin-gatsby`; also manually install `gatsby-plugin-netlify` (required for SSR, Gatsby redirects, asset caching). Generates `__api`, `__ssr`, `__dsg`, `__ipx`. Skip via `NETLIFY_SKIP_GATSBY_FUNCTIONS` (all) / `NETLIFY_SKIP_API_FUNCTION` / `NETLIFY_SKIP_SSR_FUNCTION` / `NETLIFY_SKIP_DSG_FUNCTION`.
- Gatsby 5 requires Node 18.
- Large sites: set `GATSBY_EXCLUDE_DATASTORE_FROM_BUNDLE` to load datastore from CDN (avoids max function deploy size; slower first SSR/DSG load).
- Image CDN: set `NETLIFY_IMAGE_CDN=true` (Contentful/Drupal/WordPress source plugins). **Not supported on 5.12.x with adapter — upgrade to 5.13.0+.**
- `StaticImage` and `gatsby-transformer-sharp` don't work for SSR/DSG — host images on a CDN.

### Angular
SSR auto-configured via an Edge Function. Suggested dev: `ng serve` / `4200`.
- **SSR pages are NOT subject to `_redirects` or `netlify.toml` redirects** — SSR uses Edge Functions that run before redirects. Use Angular's built-in redirects.
- Access `Request`/`Context` in SSR via `netlify.request` / `netlify.context` providers (from `@netlify/edge-functions`); unavailable client-side or during prerendering. Test locally with `netlify serve`.
- `NgOptimizedImage` auto-uses Image CDN; set `remote_images` (array of regex) under `[images]` in `netlify.toml`.

### Express
Node 18.14.0+. Deploy as a Netlify Function via `serverless-http`:
```bash
npm i express serverless-http @netlify/functions @types/express
```
```ts
// netlify/functions/api.ts
import express, { Router } from "express";
import serverless from "serverless-http";
const api = express();
const router = Router();
router.get("/hello", (req, res) => res.send("Hello World!"));
api.use("/api/", router);
export const handler = serverless(api);
```
```toml
[functions]
  external_node_modules = ["express"]
  node_bundler = "esbuild"
[[redirects]]
  force = true
  from = "/api/*"
  status = 200
  to = "/.netlify/functions/api/:splat"
```
No frontend: set a placeholder build command (e.g. `echo Building Functions`). All Function limits apply; not recommended as background/scheduled functions.

### Hydrogen
Shopify stack on React Router 7. **SSR only on Netlify Edge Functions — Netlify Functions NOT officially supported.** Node 24+. Use the starter:
```bash
npm create @shopify/hydrogen@latest -- --template https://github.com/netlify/hydrogen-template
cp .env.example .env && npm run dev
```

## Static-site gotchas

### Hugo
Set `HUGO_VERSION` (any release after 0.19) in `[build.environment]` — a missing/mismatched version causes `exit code: 255`. Install themes as **git submodules** (`git submodule add ...`), not `git clone`.

### Eleventy
`eleventy` / `_site`. **Build plugins require editing `.gitignore`: change `node_modules` to `**/node_modules/**`** — otherwise Netlify plugins and Eleventy collide on `.netlify/plugins/node_modules/` and the build errors.

## Vite meta-framework support matrix
Astro (auto on 5.12+), Nuxt (via `@netlify/nuxt`), TanStack Start (via `@netlify/vite-plugin-tanstack-start`), React Router, SolidStart — all **full**. SvelteKit — **experimental**.

## Deploy via CLI (Express, Nuxt, React, Vite)
```sh
npm install netlify-cli -g
netlify init
```
Follow prompts to create/link the site and set build settings.

<!-- Node version floors (18.14.0+) are stated per-framework where documented; no cross-framework build-image default is given in sources. -->

<!-- system: agent-context/frameworks/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (frameworks)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Per-framework deep guides live in this skill: `references/astro.md`,
   `references/nextjs.md`, `references/nuxt.md`, `references/sveltekit.md`,
   `references/tanstack.md`, `references/vite.md` — route framework-specific
   work there before improvising.
2. Next.js: modern runtime (v5, Next ≥13.5) only — treat the legacy adapter
   as read-only history, never a recommendation.
3. Remove any SPA catch-all (`/* → /index.html 200`) when adopting an SSR
   adapter — user redirects beat adapter-generated routes, so a leftover
   catch-all silently serves static `index.html` for SSR pages and API routes.
4. Any env var change — client- or server-side — requires a redeploy. Values
   are injected at build time; editing one in the UI/CLI does not reach the
   live site or already-deployed functions until a new build runs.
5. `netlify dev` with both a custom `command` and a `targetPort` requires
   `framework = "#custom"` in the `[dev]` block — otherwise the detector runs
   and the custom command is silently ignored.
6. Never use a client prefix (`VITE_`, `NEXT_PUBLIC_`, `PUBLIC_`,
   `NUXT_PUBLIC_`, `REACT_APP_`, `GATSBY_`, `VUE_APP_`) for secrets —
   client-prefixed vars are inlined into the browser bundle.
7. Next.js skew protection is version-conditional: below Next 14.1.4 the
   `NETLIFY_NEXT_SKEW_PROTECTION` env var is not sufficient on its own —
   `experimental.useDeploymentId` (plus `useDeploymentIdServerActions` when
   server actions are used) must also go in `next.config.js`. Always ask for
   or state the version condition; never present the env var as the whole
   setup.
8. Client `fetch` calls are not covered by Next.js skew protection by default.
   Give both options. Next.js 15.4+ has an experimental `useSkewCookie` flag
   that carries the deployment identifier in a cookie so it rides along on
   client `fetch` calls; Netlify supports it, but say it is not
   production-ready and that it holds visitors on the older deploy until the
   cookie clears. The other option, on any version, is adding
   `x-deployment-id` with `process.env.NEXT_DEPLOYMENT_ID` per call.

Referenced files: 6

netlify-functions18.6 KB

View saved version →

---
name: netlify-functions
description: Write, configure, and deploy Netlify serverless functions in TypeScript, JavaScript, or Go. Use this when adding an API endpoint or backend route, adding a contact form handler, wiring auth or Identity signup/login hooks, building streaming or AI-proxy responses, scheduling cron jobs, running long background jobs (batch processing/scraping), reacting to deploy or form events, setting up rate limiting or region/memory config, or reading environment variables and secrets inside a function. Covers file locations, the Request/Context/Response handler shape, path routing, config options, and local testing with netlify dev.
---

# Netlify Functions

Reach for the modern default-handler API (`.mts` TypeScript). Export a default async handler taking a web `Request` and a Netlify `Context`, returning a web `Response`. Avoid the legacy AWS Lambda handler shape unless writing Go or migrating old code (see Legacy at the end).

## File locations

- Default directory: `netlify/functions/` (relative to base directory). Keep it **outside** your publish directory or source files ship as static assets.
- A function is one file or a subdirectory whose entry file is named `index` or matches the subdirectory name. All of these create a function `hello`:
  - `netlify/functions/hello.mts`
  - `netlify/functions/hello/hello.mts`
  - `netlify/functions/hello/index.mts`
- Use `.mts` (TS) / `.mjs` (JS) for ES modules. `.cts`/`.cjs` force CommonJS; `.ts`/`.js` follow the nearest `package.json` `"type"`.

## Minimal function

No `config` export. Serves at `/.netlify/functions/hello`.

```ts title="netlify/functions/hello.mts"
import type { Context } from "@netlify/functions"

export default async (req: Request, context: Context) => {
  return new Response("Hello, world!")
}
```

Install types: `npm install @netlify/functions` (required for TS types; optional for JS).

Read env vars and secrets with `Netlify.env.get()`:

```ts
const apiKey = Netlify.env.get("STRIPE_SECRET_KEY")
```

Never hardcode secrets. For the variable to exist at runtime its scope must include **Functions**. Variables set in `netlify.toml` are NOT available to functions. Values are frozen per deploy — change them and redeploy to apply.

**Response headers are set in code** on the returned `Response`. `[[headers]]` in `netlify.toml`, `_headers`, and redirect header rules apply ONLY to static CDN responses, not function responses. Do not add CORS headers unless explicitly requested.

## Custom path routing

Set `config.path` to route to custom URLs. When set, the function serves ONLY at that path — not at `/.netlify/functions/<name>`.

```ts title="netlify/functions/travel.mts"
import type { Config, Context } from "@netlify/functions"

export default async (req: Request, context: Context) => {
  const { city, country } = context.params
  return new Response(`You're visiting ${city} in ${country}!`)
}

export const config: Config = {
  path: "/travel-guide/:city/:country",
}
```

- Multiple paths: `path: ["/cats", "/dogs"]`.
- Patterns: `path` supports [`URLPattern`](https://developer.mozilla.org/en-US/docs/Web/API/URL_Pattern_API) syntax — `path: ["/sale/*", "/item/:sku"]`. Named groups land on `context.params`. For the query string use `req.url`.
- `excludedPath`: carve exceptions, e.g. `excludedPath: ["/product/*.css"]` with `path: "/product/*"`.
- `preferStatic: true`: let a real static file at the URL win.
- `method`: restrict methods, e.g. `method: ["GET", "POST"]`.

## Fetchable module shape (alternative)

Equivalent to the bare handler; carries `config` inline and lets you add event handlers.

```ts
import type { NetlifyFunction } from "@netlify/functions"

export default {
  fetch: (req, context) => new Response("Hello, world!"),
  config: { path: "/hello" },
} satisfies NetlifyFunction
```

## Context object

Second handler argument (or `getContext()` from `@netlify/functions` when out of handler scope — throws outside a request; wrap in try/catch).

- `context.params` — named path params.
- `context.geo` — `city`, `country.code/name`, `latitude`, `longitude`, `subdivision`, `timezone`, `postalCode`.
- `context.ip` — client IP string.
- `context.cookies` — `get(name)` / `set(options)` / `delete(name|options)`. Cross-subdomain cookies need a custom domain (`netlify.app` is on the Public Suffix List).
- `context.site` — `id`, `name`, `url`. `context.deploy` — `context`, `id`, `published`, `skewProtectionToken`. `context.account.id`. `context.server.region`. `context.requestId`.
- `context.waitUntil(promise)` — run work after the response is sent (analytics, logs) without blocking. Billing/log duration counts until the promise settles. Available for functions deployed on/after 2025-03-20.

⚠️ Under `netlify dev`, `context.geo` and `context.ip` are **mocked** — placeholder values that never change. Don't conclude geo code is broken locally. Exercise branches with `netlify dev --geo=mock --country=DE` and verify on a real deploy.

## Config object

Export `const config` (or the `config` property of a Fetchable module):

- `path` / `excludedPath` — `string | string[]`, must start with `/`.
- `method` — one method or array.
- `preferStatic` — `boolean`.
- `background` — `boolean` (see Background).
- `schedule` — cron string (see Scheduled). Mutually exclusive with `path`/`excludedPath`.
- `rateLimit` — `{ action: 'rate_limit'|'rewrite', aggregateBy: 'domain'|'ip'|[...], to?, windowSize, windowLimit }`.
- `memory` / `vcpu` — see below; mutually exclusive.
- `region` — airport code; see below.

## Integrations

```ts title="netlify/functions/users.mts"
import type { Config } from "@netlify/functions"
import { getDatabase } from "@netlify/database"

const db = getDatabase()

export default async (req: Request) => {
  const users = await db.sql`SELECT id, email FROM users LIMIT 10`
  return Response.json({ users })
}

export const config: Config = { path: "/users" }
```

Blobs: `import { getStore } from "@netlify/blobs"`; `getStore("uploads").set(key, await req.blob())`.

`purgeCache()` from `@netlify/functions` invalidates the edge cache from inside a function:

```ts
import { purgeCache } from "@netlify/functions"

export default async () => {
  await purgeCache({ tags: ["products"] }) // omit tags to purge all
  return new Response("Purged!", { status: 202 })
}
```

## Streaming responses

Return a `ReadableStream` as the `Response` body. Limits: **60s execution, 20 MB response**.

```ts
export default async (req: Request) => {
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${Netlify.env.get("OPENAI_API_KEY")}`,
    },
    body: JSON.stringify({ model: "gpt-4o-mini", stream: true, messages: [/* ... */] }),
  })
  return new Response(res.body, { headers: { "content-type": "text/event-stream" } })
}
```

To build a stream manually, `new ReadableStream({ start(controller) { controller.enqueue(...); controller.close() } })`.

## Background functions (long-running)

`config.background: true`. Client gets an immediate `202`; the return value is discarded; runs up to **15 minutes**. No streaming. Retries: on invocation error, retry after 1 min, then again 2 min later. Send results somewhere other than the client.

```ts title="netlify/functions/process.mts"
import type { Config } from "@netlify/functions"

export default async (req: Request) => {
  // Long-running work. Client already has its 202.
}

export const config: Config = { background: true, path: "/process" }
```

Limits: background payload **256 KB**. Legacy `-background` filename suffix still works but prefer `config.background`.

## Scheduled functions (cron)

`config.schedule` with a cron expression, executed in **UTC**. The request body is JSON with `next_run` (ISO-8601). Inline config is TS/JS only — Go must use `netlify.toml`.

Always compute the UTC time for the target local hour. E.g. 9 AM ET → `"0 13 * * *"` UTC (note this shifts by an hour across DST; pick the UTC offset you need). Prefer explicit cron over `@daily`/`@hourly` shortcuts, which can't target a specific local hour.

```ts title="netlify/functions/daily-digest.mts"
import type { Config } from "@netlify/functions"

export default async (req: Request) => {
  const { next_run } = await req.json()
  console.log("Next invocation at:", next_run)
}

export const config: Config = {
  schedule: "0 13 * * *", // 9 AM ET (EST); UTC
}
```

Via `netlify.toml` (all languages):

```toml
[functions."daily-digest"]
  schedule = "0 13 * * *"
```

Constraints: **30s limit** (use background for longer); only fire on **published deploys** (not Deploy Previews/branch deploys — invoke manually with **Run now**); no URL invocation; no streaming; no request payloads/POST data; incompatible with Split Testing. All extensions supported **except** `@reboot` and `@annually`.

## Platform-event functions

Export a default object with handlers named after events. They always run in the background — no response to a client. Combine with `fetch` in the same function. Every handler is fully typed; import event types from `@netlify/functions`.

```ts title="netlify/functions/on-deploy.mts"
import type { DeploySucceededEvent, DeployFailedEvent } from "@netlify/functions"

export default {
  deploySucceeded(event: DeploySucceededEvent) {
    console.log(`Deploy ${event.deploy.id} succeeded for ${event.site.name}`)
  },
  deployFailed(event: DeployFailedEvent) {
    console.log(`Deploy ${event.deploy.id} failed: ${event.deploy.errorMessage}`)
  },
}
```

**Deploy events** (`event.deploy`, `event.site`; return `void`): `deployBuilding`, `deploySucceeded`, `deployFailed`, `deployDeleted`, `deployLocked`, `deployUnlocked`.

**Identity events** (`event.user`, only `id` guaranteed):

| Handler | Can deny? | Can mutate? |
|---|---|---|
| `userValidate` | Yes | Yes |
| `userSignup` | Yes | Yes |
| `userLogin` | Yes | Yes |
| `userModified` | Yes | Yes |
| `userDeleted` | No | No |

- Deny: call `event.deny()` inside the handler → end user gets `401`. First function to deny aborts the chain.
- Mutate: return `{ user: {...} }` to persist changes; return `undefined` to pass through.

**Form events**: `formSubmitted` → `event.data` (object keyed by field name). Return `void`.

Multiple functions can handle the same event (all run). Netlify signs each event (JWS) and verifies before invoking, blocking external requests. Legacy filename convention (file named after the event, payload via `await req.json()` → `payload`) still works but prefer typed handlers.

## Region

⚠️ Do NOT override `config.region` unless the user states a specific reason (co-located DB/backend, data residency, regional audience). The default `cmh` (US East, Ohio) is deliberate.

When justified — e.g. an EU-resident database:

```ts
export const config: Config = { path: "/eu-data", region: "dub" }
```

Airport codes (self-serve): `cmh`, `dub`, `fra`, `gru`, `iad`, `lhr`, `nrt`, `pdx`, `sfo`, `sin`, `syd`, `yul`. Support-assisted: `cdg`, `mxp`. Each function runs in exactly one region (no multi-region geo-routing). Region selection needs Pro/Enterprise. Framework-adapter-generated functions can't take `export const config` — set region at project level in the UI under **Cloud compute > Functions > Region**. After changing region, **redeploy**. Function-level region beats the site-level UI setting.

## Memory / vCPU

⚠️ Do NOT set `config.memory` or `config.vcpu` speculatively — billing scales linearly with size. Raise them only for known memory/compute-intensive work (AI inference, image/PDF, large JSON/CSV) or observed OOM/timeouts caused by the function's own work.

When justified (e.g. observed OOM processing large PDFs):

```ts
export const config: Config = { path: "/heavy", memory: "2gb" } // or memory: 2048
```

- `memory`: 1024–4096 MB. `vcpu`: 0.5–2.0 (0.5 → 1024 MB, 2.0 → 4096 MB). Mutually exclusive; Netlify sizes the other automatically. Needs Credit-based Pro/Enterprise. Via `netlify.toml`: `[functions.heavy]\n  memory = "2gb"`.

## Bundling & files on disk

⚠️ Files read from disk at runtime (`fs.readFile` on templates, JSON, WASM) are **not bundled**: works under `netlify dev`, ENOENT in production. Prefer importing static data as a module. Otherwise declare it in `netlify.toml`:

```toml
[functions]
  included_files = ["files/*.md"]
  external_node_modules = ["package-1"]
```

⚠️ The combined env-var limit is **~4 KB** for ALL functions (they run on AWS Lambda) — no Netlify setting raises it. Keep large payloads (service-account JSON, PEM keys) out of env vars; use a bundled file, Blobs, or a runtime fetch.

JS-only esbuild: `[functions]\n  node_bundler = "esbuild"`.

## Limits (not configurable)

- Synchronous execution: **60s**. Scheduled: **30s**. Background: **15 min**.
- Buffered request/response payload: **6 MB** (binary is Base64-encoded, ~30% overhead → effective **4.5 MB** binary limit).
- Streamed response: **20 MB**. Background payload: **256 KB**.

## Local testing & deploy

- Most frameworks emulate functions in their dev server. Vite frameworks (Astro, Nuxt, TanStack Start, React Router): install `@netlify/vite-plugin` and run the dev server. Next.js and anything else: use the [Netlify CLI](https://docs.netlify.com/api-and-cli-guides/cli-guides/local-development/) (`netlify dev`).
- Scheduled functions don't fire on a schedule locally — invoke once with `netlify functions:invoke <name>`.
- Deploy: push to Git for continuous deployment, or use the Netlify CLI/API.
- Logs & metrics live in the Netlify UI; stream with the CLI. All deployed function versions appear under the **Functions** tab; use the search field at the top of the list to filter functions by name, and the separate filter to select a branch or enter a Deploy Preview number.

## Node runtime version

Runtime follows the build's Node.js version (fallback: Node.js 24). Override by setting env var `AWS_LAMBDA_JS_RUNTIME` (e.g. `nodejs24.x`) via UI/CLI/API — **not** `netlify.toml` — then redeploy. ES modules: `__dirname`/`__filename` unavailable, use `import.meta.url`; named imports of CommonJS packages fail, use a default import.

## Legacy / Go (avoid unless needed)

Go must use the [Lambda-compatible API](https://docs.netlify.com/build/functions/lambda-compatibility/?fn-language=go); Go routing/region/memory are set in `netlify.toml`. For migrating Lambda-style JS/TS, `@netlify/aws-lambda-compat` wraps an AWS handler:

```ts
import { withLambda } from "@netlify/aws-lambda-compat"
import type { HandlerContext, HandlerEvent, HandlerResponse } from "@netlify/aws-lambda-compat"

export default withLambda(async (event: HandlerEvent, context: HandlerContext): Promise<HandlerResponse> => {
  const name = event.queryStringParameters?.name ?? "World"
  return { statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify({ name }) }
})
```

Lambda-compat mode enforces the 4 KB env-var limit; [upgrade to modern functions](https://developers.netlify.com/guides/migrating-to-the-modern-netlify-functions/) to remove it.

<!-- system: agent-context/functions/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (functions)

These are org conventions, not docs facts — they are merged into the rendered
skill by ctx-gen and are never generated. Extracted from the previous
hand-written netlify-functions skill; owned by the skills maintainer.

1. Use TypeScript (`.mts`) when possible.
2. Access environment variables via `Netlify.env.get()` (prefer it over
   `process.env` for consistency).
3. Never add CORS headers unless explicitly requested.
4. Store secrets in environment variables, never in code.
5. `context.geo` and `context.ip` are mocked under `netlify dev` — placeholder
   values, not the real location or client IP. Don't conclude geo code is
   broken because local values never change; exercise branches with
   `netlify dev --geo=mock --country=DE` and verify on a deploy.
6. Do NOT set `config.memory` or `config.vcpu` speculatively. Raise them only
   for known memory/compute-intensive work or observed OOM/timeouts caused by
   the function's own work — billing scales linearly with size.
7. Do NOT override `config.region` unless the user has stated a specific
   reason (co-located database/backend, data residency, regional audience).
   The `cmh` default is a deliberate choice.
8. Files read from disk at runtime (`fs.readFile` on templates, JSON, WASM)
   are not bundled: works under `netlify dev`, ENOENT in production. Prefer
   importing static data as a module; otherwise declare the file with a
   scoped `included_files` entry in `netlify.toml`.
9. The ~4 KB combined environment-variable limit applies to ALL functions
   (they run on AWS Lambda), not just Lambda-compat mode. Keep large payloads
   (service-account JSON, PEM keys) out of env vars — use a bundled file,
   Blobs, or a runtime fetch. No Netlify setting raises this cap.
10. The body's FIRST function example must be the minimal default: no
    `config` export at all, stating the function serves at
    `/.netlify/functions/<name>`. Custom `path` routing appears only in a
    later example — agents imitate the first example they see.
11. Never demonstrate `memory`, `vcpu`, or `region` in a generic example —
    show them only attached to an explicit stated reason (observed OOM,
    co-located backend, data residency).
12. Scheduled-function examples use a real cron expression with the UTC
    conversion spelled out (e.g. 9 AM ET → `"0 13 * * *"` UTC, noting DST) —
    never only `@hourly`/`@daily` shortcuts, which can't target a specific
    local hour.
13. The body must state that `[[headers]]` in netlify.toml, `_headers`, and
    redirect header rules apply ONLY to static CDN responses — response
    headers for a function are set in code on the returned `Response`.
14. When asked to build a function that performs specific work (generate a
    report, process an upload, send a digest), implement the work — pick a
    real library where one is needed and write the operation end to end.
    Never deliver the core task as a `not implemented` stub behind finished
    plumbing: a function whose central branch throws is not a working
    answer, however complete its config and routing.
15. `config.background: true` is the documented, currently-supported way to
    make a function background (the `-background` filename suffix also still
    works). If a locally installed bundler doesn't recognize the flag,
    suspect version skew first: check and upgrade the local tooling, and
    keep local-compatibility findings separate from claims about platform
    support — never remove the docs-recommended flag from an answer based
    solely on an older installed schema.
netlify-identity13.9 KB

View saved version →

---
name: netlify-identity
description: Add user authentication to a Netlify site with @netlify/identity — signup/login/logout, Google/GitHub/GitLab/Bitbucket OAuth, server-side getUser() checks, role-based access control, and Identity event functions. Use it when a task involves adding a login or signup form, gating content to members or roles, "auth middleware" or verifying users in Netlify Functions or Edge Functions, handling OAuth or email-confirmation callbacks, assigning roles at signup, or customizing Identity emails. For locking a whole site to your company or employees-only access, use netlify-access-control instead.
---

# Netlify Identity

Use `@netlify/identity` (npm). For new projects it replaces the legacy `netlify-identity-widget` and `gotrue-js` — do not reach for those.

```bash
npm install @netlify/identity
```

Framework examples (Next.js/Astro/Remix/SvelteKit) and the full API reference are in the [`@netlify/identity` README on npm](https://www.npmjs.com/package/@netlify/identity).

> **Identity does not run under `netlify dev`.** Test all auth flows on a deploy — Deploy Previews work. Local dev will not complete signup/login/OAuth.

> **Identity config is dashboard-only — there is no public API.** Never curl `api.netlify.com` to flip or read Identity settings, never read tokens from local Netlify config, never probe undocumented endpoints. Enable and configure Identity at `https://app.netlify.com/projects/{site_name}/identity`.

> **Never build a from-scratch OAuth flow alongside Identity.** No provider app registration in code, no `client_id`/`secret` in source, no custom callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`. Raw OAuth beside Identity is the most common source of rework.

## Client auth (browser)

```ts
import { signup, login, logout, getUser, oauthLogin, handleAuthCallback } from '@netlify/identity'

// Register — confirmation email sent by default (unless autoconfirm is on)
const user = await signup('jane@example.com', 'securepassword', { full_name: 'Jane Doe' })

// Log in / out
await login('jane@example.com', 'securepassword')
await logout()

// Current user or null
const current = await getUser()
if (current) console.log(`Logged in as ${current.email}`)

// External provider — redirects the browser; provider is one of
// 'google' | 'github' | 'gitlab' | 'bitbucket'
oauthLogin('github')
```

> **`handleAuthCallback()` is mandatory on your landing page.** Without it, OAuth redirects, email-confirmation links, password-recovery links, and invite links never complete. Call it on page load:

```ts
import { handleAuthCallback } from '@netlify/identity'

const result = await handleAuthCallback() // falsy if no token in URL hash
if (result) console.log(result.type, result.user.email) // confirmation | invite | recovery | email change
```

Alternatives for a single token type: `recoverPassword()` (recovery), `acceptInvite()` (invite). Refresh a session with `refreshSession()`.

Don't hard-code which providers exist. Call `getSettings()` at startup and render the signup form and OAuth buttons from what it returns.

## Server-side auth (Netlify Functions & Edge Functions)

Server-side `getUser()`/`login()`/`admin.*` require modern **v2 functions** (`export default`). The v1 `export { handler }` form is not supported.

`getUser()` works in both runtimes. **`admin.*` runs ONLY in Netlify Functions — not the browser, not Edge Functions.**

```ts
// netlify/functions/me.ts — verify user
import { getUser } from '@netlify/identity'
import type { Context } from '@netlify/functions'

export default async (req: Request, context: Context) => {
  const user = await getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })
  return Response.json({ id: user.id, email: user.email })
}
```

Edge Function form is identical but imports `Context` from `@netlify/edge-functions`.

### Role checks

```ts
// netlify/functions/admin-users.ts
import { getUser, admin } from '@netlify/identity'
import type { Context } from '@netlify/functions'

export default async (req: Request, context: Context) => {
  const user = await getUser()
  if (!user) return new Response('Unauthorized', { status: 401 })
  if (!user.roles.includes('admin')) return new Response('Forbidden', { status: 403 })
  const users = await admin.listUsers()
  return Response.json({ users })
}
```

### CSRF: required for server-side auth endpoints

> Any endpoint that runs `login()`, `signup()`, or `logout()` server-side **must** call `verifyRequestOrigin(req)` at the top of the handler. It throws a 403 on origin mismatch.

```ts
// netlify/functions/login.ts
import { login, verifyRequestOrigin } from '@netlify/identity'
import type { Context } from '@netlify/functions'

export default async (req: Request, context: Context) => {
  verifyRequestOrigin(req)
  const { email, password } = await req.json()
  await login(email, password)
  return new Response(null, { status: 302, headers: { Location: '/dashboard' } })
}
```

## Identity event functions

The platform calls your handler when an Identity event occurs. Export a default object with a method per event. File: `netlify/functions/identity.mts`.

> Typed handlers (`UserSignupEvent`, `event.deny()`) require `@netlify/functions` ≥ 5.2.0. Older installs must use the legacy filename convention (`identity-signup.ts`, etc.) — see `references/authorization-and-sessions.md`.

| Handler | Fires when |
|---|---|
| `userValidate` | Signup attempt, before account creation. Block bad signups here. |
| `userSignup` | Signup completes (after email confirmation if enabled). Assign roles, sync, welcome. |
| `userLogin` | User logs in. Track/last-seen/block. |
| `userModified` | Profile updated. |
| `userDeleted` | User deleted (notification only). |

Event `user` fields are camelCase (`appMetadata`, `userMetadata`, `confirmedAt`).

```typescript
// netlify/functions/identity.mts — deny a signup
import type { UserValidateEvent } from "@netlify/functions"

export default {
  userValidate(event: UserValidateEvent) {
    if (!event.user.email?.endsWith("@example.com")) return event.deny()
  },
}
```

```typescript
// netlify/functions/identity.mts — assign roles at signup
import type { UserSignupEvent } from "@netlify/functions"

export default {
  userSignup(event: UserSignupEvent) {
    return { user: { ...event.user, appMetadata: { ...event.user.appMetadata, roles: ["member"] } } }
  },
}
```

- `event.deny()` — rejects the action; end user gets `401`, no observability error. First handler to call it aborts the chain; later subscribers are not invoked. (Legacy filename functions signal denial with a non-2xx `Response` instead.)
- Return `{ user: {...} }` to modify the record before persistence (canonical way to set roles at signup). Roles ride in the JWT, so a role change takes effect on the user's **next login or token refresh, not immediately** — see Roles & the JWT below.
- Background mode: `export const config: Config = { background: true }` — action completes immediately, handler runs async.

## Roles & the JWT

- `user.roles` is read from `app_metadata.roles`, carried in the JWT (cookie `nf_jwt`; refresh via `nf_refresh`).
- `user_metadata` — user-editable profile (`full_name`, `email`). `app_metadata` — app data incl. `roles`, not user-editable.

> **Role changes are NOT immediate.** They take effect on next login or token refresh. Changing roles does not invalidate the current JWT. Force it with `refreshSession()`.

Set roles for existing users via `admin.updateUser()` in a Netlify Function; at signup via the `userSignup` event handler above.

Deep guides for SSR/session hydration and authorization live in `references/advanced-patterns.md` and `references/authorization-and-sessions.md`.

## CDN-edge RBAC (redirect rules)

Enforced at the edge with no origin round trip. A mismatched role gets a 404 unless you add a fallback — **always pair a role-gated rule with a fallback.**

`_redirects`:
```
/admin/*  /admin/:splat  200!  Role=admin
/admin/*  /login         401!
# Multiple roles chained with commas:
/private/* /private/:splat 200! Role=editor,admin
```

`netlify.toml`:
```toml
[[redirects]]
  from = "/admin/*"
  to = "/admin/:splat"
  force = true
  status = 200
  conditions = {Role = ["editor", "admin"]}
```

Use redirect rules for path-based gating; use function-based `user.roles` checks for custom authorization logic.

## Configuration (dashboard-only)

Base: `https://app.netlify.com/projects/{site_name}/identity`. Enable with **Enable Identity**. Identity requires HTTPS — set up SSL before integrating on a custom domain.

- **Registration** (`?tab=registration#registration-preferences`): **Open** (default, anyone can sign up) or **Invite only** (all users, including external-provider logins, must be invited first).
- **Confirmation / autoconfirm** (`?tab=emails#confirmation-template`): check the box to skip email verification.
- **External providers** (`?tab=registration#external-providers`): Google/GitHub/GitLab/Bitbucket. For branded OAuth (your app name instead of "Netlify Identity"), register your app with the provider, get client ID + secret, and enter them **in the Netlify settings UI** — not in code.
- **Invitations** (`?tab=users`): enter addresses to send invites; link carries `invite_token`.
- **Password recovery**: user page → **Send reset password email**; link carries `recovery_token`.

### Emails (Pro plans or higher)

Default sender is `no-reply@netlify.com`. Custom SMTP sender and custom templates both require **Pro plans or higher**.

Template variables (Go syntax): `{{ .Email }}`, `{{ .NewEmail }}` (email-change only), `{{ .SiteURL }}`, `{{ .ConfirmationURL }}`, `{{ .Token }}`.

Custom-link hash fragments per action:
```
{{ .SiteURL }}/path/#invite_token={{ .Token }}
{{ .SiteURL }}/path/#confirmation_token={{ .Token }}
{{ .SiteURL }}/path/#recovery_token={{ .Token }}
{{ .SiteURL }}/path/#email_change_token={{ .Token }}
```

Custom template constraints: inline CSS only; absolute image links; **no `<html>`/`<head>`/`<body>` tags**; ensure your build doesn't alter Go template variables.

### Audit log (Pro plans or higher)

`?tab=audit-log`. Search with a scoped term: `author:[string]` or `action:[string]`. Action names: `login`, `logout`, `user_signedup`, `user_deleted`, `user_modified`, `token_revoked`, `token_refreshed`, `user_recovery_requested`, `user_invited`.

## External JWT providers (Enterprise)

Available on **Enterprise plans**. You may use Netlify Identity OR an external JWT provider — **not both at once**; you cannot authenticate third-party JWTs while Netlify Identity is enabled.

- Roles path: Netlify Identity `app_metadata.roles`; external provider `app_metadata.authorization.roles`. Custom path → contact support.
- JWT header must be `{"alg": "HS256", "typ": "JWT"}` (HS256 required). Payload `exp` is required and must be a future Unix Epoch time.
- Set the JWT secret at `Project configuration > General > Visitor access > JWT secret`. Project-level overrides team-level defaults.

## On failure — stop, don't guess

If callbacks 404, `/.netlify/identity/*` is unreachable, or an OAuth flow never returns: surface the error, the dashboard URL (`https://app.netlify.com/projects/{site_name}/identity`), and the setting to check (registration preference, external provider config, confirmation/autoconfirm). Then stop. Do not invent recovery commands. Remember: Identity does not work under `netlify dev` — confirm you are testing on a deploy.

Site-gating requests ("lock this site to my company", employees-only) route to the netlify-access-control skill first — Identity is the app-level user layer only.

<!-- gap: getSettings() is referenced by house rules for provider discovery but its signature/return shape is not documented in the intermediate. -->

<!-- system: agent-context/identity/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (identity)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. Deep guides live in this skill: `references/advanced-patterns.md`
   (SSR/session hydration) and `references/authorization-and-sessions.md`.
2. Identity does not work under `netlify dev` — test auth flows on deploys
   (Deploy Previews work).
3. Identity configuration has no public API — it is dashboard-only. Never curl
   `api.netlify.com` to flip or inspect Identity settings, never read auth
   tokens from `~/Library/Preferences/netlify/config.json`, never probe for
   undocumented endpoints.
4. On failure (callback 404s, `/.netlify/identity/*` unreachable, OAuth flow
   doesn't return), surface the error, the dashboard URL, and the setting to
   check — then stop. Do not invent recovery commands.
5. Never build a from-scratch third-party OAuth flow when Identity is in play —
   no provider app registration, no `client_id`/`secret` in code, no custom
   callback token exchange. Use `oauthLogin()` + `handleAuthCallback()`;
   raw OAuth beside Identity is the single most common source of rework.
6. Server-side `getUser()`/`login()`/`admin.*` require modern v2 functions
   (`export default`) — v1 `export { handler }` is not supported. Typed
   Identity event handlers (`UserSignupEvent`, `event.deny()`) require
   `@netlify/functions` ≥ 5.2.0; older installs use the legacy filenames.
7. Don't hard-code which auth providers exist — call `getSettings()` at
   startup and render the signup form and OAuth buttons from what it returns.
8. Site-gating requests ("lock this site to my company", employees-only)
   route to the netlify-access-control skill first — Identity is the
   app-level user layer only.
9. Any answer that assigns or changes roles — at signup, via `admin.*`, or in
   the dashboard — must say the change takes effect on the user's next login
   or token refresh, not immediately. Keep that sentence next to the code that
   sets the role, not only in a separate JWT section: an agent answering a
   signup question reads the signup example and stops, and it has shipped
   answers that omit the delay.

Referenced files: 2

netlify-image-cdn9.68 KB

View saved version →

---
name: netlify-image-cdn
description: Transforms images on demand via Netlify Image CDN's /.netlify/images endpoint with query parameters for resizing/cropping/format/quality. Use when adding image optimization or responsive images, converting formats (WebP/AVIF/PNG), generating thumbnails or blur placeholders, serving remote/third-party images through the CDN, allowlisting remote domains in netlify.toml, setting up image redirects or cache headers, building user-uploaded image pipelines, or debugging a 404 on /.netlify/images. Also covers framework image handling for Angular/Astro/Gatsby/Next.js/Nuxt.
---

# Netlify Image CDN

Transform images by requesting the endpoint with a `url` query parameter. This is the current and only documented surface — there is no legacy form.

```
GET /.netlify/images?url=<source>[&w=][&h=][&fit=][&position=][&fm=][&q=]
```

```bash
# resize a deployed image to 50px wide
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&w=50'
```

`url` is required; all other parameters are optional.

## Query parameters

| Parameter | Purpose | Values | Default |
|-----------|---------|--------|---------|
| `url` | Source asset (required) | Relative path or remote URL | — |
| `w` | Width in pixels | Integer | — |
| `h` | Height in pixels | Integer | — |
| `fit` | Resize behavior | `contain`, `cover`, `fill` | `contain` |
| `position` | Crop anchor when `fit=cover` | `top`, `bottom`, `left`, `right`, `center` | `center` |
| `fm` | Output format | `avif`, `jpg`, `png`, `webp`, `gif`, `blurhash` | content-negotiated |
| `q` | Quality for lossy output | Integer `1`–`100` | `75` |

## Common transformations

```bash
# resize + crop to a 50px square, retaining the left side
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fit=cover&w=50&h=50&position=left'

# convert JPEG to PNG (response carries content-type: image/png)
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=png'

# convert JPEG to AVIF at medium quality
curl -vs 'https://mysitename.netlify.app/.netlify/images?url=/owl.jpeg&fm=avif&q=50'
```

### fit behavior

- **`contain` (default):** maintains aspect ratio; one dimension may come back smaller than requested. Supply one dimension and the other is computed.
- **`cover`:** fills exactly, cropping excess. **Requires BOTH `w` and `h`** — omitting either is invalid. Use `position` to choose what's retained.
- **`fill`:** fills exactly, stretching/squishing if aspect ratios differ.

### Format notes

- `q` applies only when output is `avif`, `jpg`, `gif`, or `webp`.
- `webp` and `gif` can be static or animated.
- If `fm` is omitted, format is content-negotiated from the `Accept` header: `webp` if accepted, else `avif` if accepted, else the original format. A source-only request (no other params) still converts to `webp`/`avif` but keeps size and shape.

## Remote source images

Remote sources must be allowlisted in `netlify.toml` before transformation, or the request fails.

```toml
[images]
  remote_images = ['https://my-images\.com/.*', 'https://animals.more-images.com/[bcr]at/.*']
```

Percent-encode remote source URLs before placing them in the `url` parameter with `encodeURIComponent` — a URL containing `?` or `&` breaks otherwise.

```js
const src = `/.netlify/images?url=${encodeURIComponent('https://my-images.com/owl.jpeg?v=2')}&w=400`;
```

Constraints:
- Remote sources must be **publicly accessible**.
- Credential-bearing headers (`Authorization`, `Cookie`) are **NOT forwarded** when fetching a remote source. For authenticated sources, use URLs that carry their own authorization (e.g. S3 presigned URLs) and make sure your `remote_images` patterns match those full URLs.

### remote_images regex escaping

The only meaningful escape is the literal dot (`\.`). Forward slashes are NOT metacharacters — never write `https:\/\/`. In `netlify.toml`, use single-quoted literal strings (`'https://example\.com/.*'`) or double the backslash in double-quoted strings (`"https://example\\.com/.*"`). A bare `\.` inside double quotes is invalid TOML.

## Response codes

- Invalid transformation parameter values → `404`.
- Valid new transformation → `200` with content and matching `content-type`.
- Previously transformed (cached) image → `304`.

## Reusing parameters across images

Map a friendly path to the endpoint with a redirect/rewrite.

`_redirects`:
```
/transform-small/* /.netlify/images?url=/:splat&w=50&h=50 200
```

`netlify.toml`:
```toml
[[redirects]]
  from = "/transform-small/*"
  to = "/.netlify/images?url=/:splat&w=50&h=50"
  status = 200
```

Then `GET /transform-small/owl.jpeg` returns the transformed image. **Cross-site redirects for transformations are NOT recommended** — they can degrade site performance.

## Caching headers

Apply custom headers to source images on the site's own domain; they carry through to the transformed output.

`netlify.toml`:
```toml
[[headers]]
  for = "/source-images/*"
  [headers.values]
    Cache-Control = "public, max-age=604800, must-revalidate"
```

- Custom headers can be applied to source images on the site's domain only — NOT to remote source images (Netlify does respect cache headers the external domain sends).
- `Cache-Control` on source images applies only to browsers and CDNs in front of Netlify, NOT the Netlify Cache itself.

## Blur placeholders (fm=blurhash)

`fm=blurhash` returns a BlurHash **text string**, not image bytes. Pointing an `<img src>` (or CSS background) at it renders nothing. Fetch the string ahead of time, decode it client-side with a BlurHash library (https://blurha.sh), and load the real image as a separate request without `fm=blurhash`.

## Local development

The `/.netlify/images` endpoint, `[images]` allowlisting, and image redirects only exist under `netlify dev` (Netlify CLI). A local **404 on `/.netlify/images` almost always means a framework dev server (`vite`, `next dev`, `astro dev`) is running instead of `netlify dev`** — the URL itself is usually fine. Start the local environment with `netlify dev`.

## User-uploaded image pipelines

For pipelines composing Functions + Blobs + Image CDN (handling user-uploaded images), see `references/user-uploads.md`.

## Framework image handling

Many frameworks route their built-in image optimization through Netlify Image CDN — use the framework's standard image component/syntax and only configure the remote allowlist. For unlisted frameworks, call `/.netlify/images` directly.

| Framework | Prerequisites | Remote allowlist location |
|-----------|---------------|---------------------------|
| Angular | None; `NgOptimizedImage` uses it automatically | `[images] remote_images` in `netlify.toml` |
| Astro | None; `<Image />` uses it automatically | `image.domains` or `image.remotePatterns` in `astro.config.mjs` |
| Gatsby (both 5.13+ and 5.11 or earlier) | Set env `NETLIFY_IMAGE_CDN=true`; use Contentful/Drupal/WordPress source plugins | `[images] remote_images` in `netlify.toml` |
| Next.js | Next.js 13.5+ and Next.js adapter v5 | `remotePatterns` in `next.config.js` |
| Nuxt | None; `nuxt/image` module uses it automatically | `image.domains` in `nuxt.config.ts` |

Setup guides: [Angular](https://docs.netlify.com/build/frameworks/framework-setup-guides/angular#netlify-image-cdn), [Astro](https://docs.netlify.com/build/frameworks/framework-setup-guides/astro#netlify-image-cdn), [Gatsby](https://docs.netlify.com/build/frameworks/framework-setup-guides/gatsby/#netlify-image-cdn), [Next.js](https://docs.netlify.com/build/frameworks/framework-setup-guides/nextjs/overview), [Nuxt](https://docs.netlify.com/build/frameworks/framework-setup-guides/nuxt#netlify-image-cdn).

## Additional constraints

- Deploy behavior: transforms respect [atomic deploys](https://docs.netlify.com/build/caching/caching-overview#automatic-invalidation-with-atomic-deploys); changing a source image in a new deploy re-runs transforms on subsequent requests.
- [Split Testing](https://docs.netlify.com/manage/monitoring/split-testing/) is NOT supported — image results may be inconsistent across split test branches.
- Netlify Image CDN is NOT part of Netlify's HIPAA-compliant hosting offering.

Interactive parameter playground: https://image-cdn-playground.netlify.app/

<!-- system: agent-context/image-cdn/system.md — human-owned, merged by ctx-gen; edit system.md, not this section -->
# Netlify house rules (image-cdn)

These are org conventions, not docs facts — merged into the rendered skill by
ctx-gen and never generated. Owned by the skills maintainer.

1. For user-uploaded image pipelines (Functions + Blobs + Image CDN
   composed), see `references/user-uploads.md` in this skill — an authored
   guide with no single docs source.
2. Percent-encode remote source URLs before placing them in the `url`
   parameter (`encodeURIComponent`) — URLs containing `?` or `&` break
   otherwise.
3. `fm=blurhash` returns a BlurHash TEXT string, not image bytes. Pointing an
   `<img src>` (or CSS background) at it renders nothing — fetch the string
   ahead of time, decode it client-side with a BlurHash library, and load the
   real image as a separate request without `fm=blurhash`.
4. A local 404 on `/.netlify/images` almost always means a framework dev
   server (`vite`, `next dev`, `astro dev`) is running instead of
   `netlify dev` — the endpoint, `[images]` allowlisting, and image redirects
   only exist under `netlify dev`. The URL itself is usually fine.
5. In `remote_images` patterns, the meaningful regex escape is the dot;
   forward slashes are not metacharacters — do not write `https:\/\/`.
   In `netlify.toml`, use a single-quoted literal string
   (`'https://example\.com/.*'`) or double the backslash in a
   double-quoted string (`"https://example\\.com/.*"`) — a bare `\.`
   inside double quotes is invalid TOML.

Referenced files: 1

netlify-mcp-servers16.6 KB

View saved version →

---
name: netlify-mcp-servers
description: Build, deploy, and secure Model Context Protocol (MCP) servers on Netlify. Use whenever the task involves creating an MCP server, exposing an app or API to AI agents as MCP tools, letting Claude / Cursor / Claude Code call a custom remote server, or adding MCP tools to an existing Netlify site. Covers the MCP SDK + Streamable HTTP transport on a Netlify Function, authentication (single shared secret vs per-user API keys with Netlify Identity), read/write safety, file uploads, and connecting clients. Use even when the user just says "MCP", "tool server for an agent", or "let an AI use my API".
---

# Netlify MCP Servers

An MCP server exposes **tools** (and optionally resources/prompts) that an AI client — Claude Desktop, Claude Code, Cursor — can call. On Netlify, a remote MCP server is just **one Netlify Function** that speaks the MCP protocol over HTTP. This skill gets you a working, secure server and connects a client to it.

**"Netlify MCP" means two different things — make sure you're building the right one.** Netlify publishes its *own* hosted MCP server that lets an AI client operate the **Netlify platform** on your behalf — create projects, trigger deploys, manage env vars and infrastructure through your Netlify account. You don't write that one; you point your client at Netlify's hosted MCP server per Netlify's MCP-server docs (and see the **netlify-agent-runner** skill for running agents against your site). This skill is the *other* thing: building **your own** MCP server — an endpoint that exposes *your* app's tools and data to an agent — hosted on a Netlify Function. If the ask is "let my agent manage my Netlify sites/deploys/env vars," that's the hosted Netlify MCP server, not a function you write.

The same setup works two ways:

- **Standalone server** — a repo whose only job is the MCP endpoint (e.g. wrapping a third-party API).
- **Added to an existing app** — one more function alongside your site. Have its tools call the **same service/data layer your UI and REST routes already use**, so logic isn't duplicated.

## Before you build

Decide one thing up front, because it shapes the auth code:

- **Who calls this server?** Just you (a personal/single-user server) → use a **single shared secret**. Multiple people, each acting as themselves → use **per-user API keys** backed by Netlify Identity. See [authentication](references/authentication.md).

If you're not sure, start with the single shared secret — it's a few lines and you can layer per-user keys on later. I'll default to that unless you say otherwise.

## Stack

Use the official MCP SDK with its Web-standard Streamable HTTP transport, running statelessly inside a Netlify Function.

```bash
npm install @modelcontextprotocol/sdk zod
```

A Netlify Function already speaks the web platform — it receives a `Request` and returns a `Response`. The SDK ships a transport built on exactly those primitives, `WebStandardStreamableHTTPServerTransport` (the same core the SDK runs on internally, and what Cloudflare Workers / Deno / Bun use): you hand it the `Request` and return the `Response` it produces — no adapter, no version pin. Older guides reach for the Node-flavored `StreamableHTTPServerTransport` plus a `fetch-to-node` bridge to synthesize the Node `req`/`res` objects it expects; on Netlify you need neither, and skipping them is both simpler and what's verified to work here.

One gotcha, independent of all this: the transport returns **HTTP 406** to any POST whose `Accept` header lacks *both* `application/json` and `text/event-stream`. That's an MCP-spec requirement the *client* must satisfy — a 406 means fix the client's `Accept` header, not the server. Letting the SDK own the protocol also means you don't hand-maintain JSON-RPC framing or the protocol-version handshake.

## The server function

With the Web-standard transport this is a few lines — most of what older guides show was the Node bridge, which you don't need. Put it in `netlify/functions/mcp.ts`:

```typescript
import type { Config, Context } from "@netlify/functions";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
import { z } from "zod";
import { checkBearer } from "../lib/mcp/bearer"; // see Authentication

function buildServer() {
  const server = new McpServer({ name: "my-mcp", version: "0.1.0" });

  server.tool(
    "get_item",
    "Fetch a single item by id. Read-only.",
    { id: z.string().describe("The item's unique id") },
    async ({ id }) => ({
      content: [{ type: "text", text: JSON.stringify(await getItem(id)) }],
    }),
  );

  return server;
}

export default async (req: Request, _context: Context) => {
  if (!checkBearer(req)) return new Response("Unauthorized", { status: 401 });

  // Stateless JSON server: it only does request/response over POST. Reject other
  // methods — a GET makes the transport open an SSE stream that never closes, which
  // a serverless function can't serve (you'll get a 502).
  if (req.method !== "POST") return new Response("Method not allowed", { status: 405 });

  // Fresh server + transport per request, no session to persist. enableJsonResponse
  // returns one application/json body instead of an SSE stream — the right fit here.
  const server = buildServer();
  const transport = new WebStandardStreamableHTTPServerTransport({
    sessionIdGenerator: undefined,
    enableJsonResponse: true,
  });

  // Hand over the Web Request, return the Web Response. The transport owns JSON-RPC
  // framing, body parsing (a malformed body comes back as a clean 400), and the handshake.
  await server.connect(transport);
  return transport.handleRequest(req);
};

export const config: Config = { path: "/mcp" };
```

That's a complete, deployable server. Everything else is tools, auth, and safety.

## Browser-based clients and CORS

Netlify Functions do **not** add CORS headers for you, and the server above returns 405 to every non-POST method — including the `OPTIONS` preflight a browser sends. That's fine for the normal case: native MCP clients (Claude Code, Cursor, Claude Desktop, the `mcp-remote` bridge) are **not** browsers and don't enforce the same-origin policy, so they need no CORS at all — which is why those clients work while a browser call doesn't.

It only matters when your MCP client runs **in a browser** — a web app calling the server cross-origin. Then the browser blocks the request unless the response carries `Access-Control-Allow-Origin`, and it first sends an `OPTIONS` preflight that must come back `2xx` with `Access-Control-Allow-Methods` (including `POST`) and `Access-Control-Allow-Headers` (including `Authorization` and `Content-Type`). A "blocked by CORS policy: No Access-Control-Allow-Origin header" error in the browser console is this — not a broken server or a platform bug. Answer the preflight in the function itself, **before** the 405 check, and echo the CORS headers on the POST response too:

```typescript
const CORS = {
  "Access-Control-Allow-Origin": Netlify.env.get("MCP_ALLOWED_ORIGIN") ?? "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Authorization, Content-Type, Mcp-Session-Id",
};

// In the handler, before the 405 check:
if (req.method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
// ...then reject other non-POST methods with 405, and add CORS to the transport's Response.
```

The function must set these headers itself — don't treat a browser CORS error as something to escalate to Netlify or route around by loosening auth.

## Defining tools

Each tool is a `name`, a one-line `description`, a `zod` input schema, and a handler that returns `{ content: [...] }`. The description and parameter `.describe()` text are the only thing the model sees — write them like API docs for an agent: say what the tool does, when to use it, and call out anything irreversible.

As the count grows, give each tool its own module and register them in `buildServer()`. Servers with many tools often keep a registry (an array of `{ name, description, inputSchema, handler }`) and wire `tools/list` + `tools/call` once — the transport setup above is identical either way.

## Authentication

The MCP client must prove it's allowed to call your server. Every request carries `Authorization: Bearer <token>`; reject anything else with a 401.

**Single shared secret** (personal / single-user). One env var, compared in constant time. Put this in `netlify/lib/mcp/bearer.ts`:

```typescript
import { timingSafeEqual } from "node:crypto";

export function checkBearer(req: Request): boolean {
  const expected = Netlify.env.get("MCP_BEARER_TOKEN");
  if (!expected) return false;
  const match = req.headers.get("authorization")?.match(/^Bearer\s+(.+)$/i);
  if (!match) return false;
  const a = Buffer.from(match[1]);
  const b = Buffer.from(expected);
  // Length check first because timingSafeEqual throws (RangeError) on unequal-length
  // buffers. The token is fixed-length, so the early return leaks nothing useful.
  return a.length === b.length && timingSafeEqual(a, b);
}
```

Generate the token with `openssl rand -hex 32` and store it as a secret env var.

**Per-user API keys** (multi-user). Netlify Identity gates a web UI where each user mints their own keys; you store only a **hash** of each key (never the plaintext) tied to that user, resolve the key to a user on every request, and flow that user into your tool handlers so tools act as the right person. Full pattern — schema, generation, hashing, revocation, resolving the user — in [authentication](references/authentication.md).

**Start simple with scoping.** The simplest model is all-or-nothing: a valid key can call every tool as the user it belongs to — usually the right starting point. Add per-key scopes when a concrete need appears (e.g. a read-only key), and grow into per-tool scopes or role tiers if the app genuinely calls for them. If a fuller RBAC design is requested, lead with the simple baseline and layer scopes on top of it, rather than treating the full hierarchy as required up front.

## Safety and permissions

Tools are a public API handed to an autonomous agent. Be deliberate:

- **Expose the least that does the job.** Separate reads from writes, and think hard before exposing destructive tools. A common, sound choice is to **omit delete tools entirely** and keep destructive actions in a human-operated UI.
- **Guard irreversible or public actions** by putting explicit instructions in the tool's description — e.g. "show the user the exact text and get confirmation before posting." This is a soft, model-level guard, so back it with a real kill switch: a token you can revoke instantly.
- **Keep the client's credential separate from your backend's.** The client authenticates to your server (bearer/API key); your server authenticates to the database or third-party API with its *own* secret. Never pass your backend god-key out to the client.
- **Use least-privilege backend credentials** — app passwords or scoped tokens, not account-level ones, so a leak is contained and revocable.
- **Validate inputs** (your `zod` schemas do this) and **log every tool call** so you can see what the agent did — `console.info` shows up in Netlify function logs.

## Rate limiting

An MCP server is a public endpoint an autonomous agent can hit in a tight loop — cap it. Netlify Functions have **built-in declarative rate limiting**, so don't hand-roll a counter (a per-instance in-memory counter wouldn't hold across function instances anyway — see the next section). Add a `rateLimit` block to the function's `config` export:

```typescript
export const config: Config = {
  path: "/mcp",
  rateLimit: {
    windowSize: 60,               // time window in seconds; capped at 180
    windowLimit: 100,             // max requests per window
    aggregateBy: ["ip", "domain"], // group by ip, domain, or both
  },
};
```

Over the limit the platform returns HTTP `429` by default (or set `action: "rewrite"` with a `to` path to send excess traffic to a dedicated page). Function rate limits live **only** in the function's `config` export — they **cannot** be defined in `netlify.toml`.

## File uploads

When a tool needs the agent to supply a file (an image to post, a doc to attach), don't push the bytes through the tool call as base64 — it bloats the model's context and runs into payload limits. Instead hand the agent a short-lived, single-use **presigned URL** to `PUT` the raw bytes to, store them in **Netlify Blobs**, and reference the file by a stable key from your other tools. Sign the URL with an **HMAC-SHA256** over the upload id, content-type, size, and expiry, keyed by a **secret env var**, and **verify it in constant time** — the signature *is* the authorization, so the `PUT` carries no bearer token. On the upload endpoint, enforce the declared content-type and size and reject replays. Full three-step flow (`prepare_upload` → `PUT` → `finalize_upload`) with code: [file uploads](references/file-uploads.md).

## State doesn't survive between requests

Every request builds a fresh server and transport, and any invocation may land on a **different** — or cold-started — function instance. Module-level memory is not shared between instances and not durable across cold starts. So state you need to persist between calls **cannot** live in a module-scoped `Set`/`Map`/variable: single-use / replay tracking for the presigned uploads above, idempotency keys, "already processed this id" guards, per-user counters you track by hand. An in-memory guard *looks* correct locally and on one warm instance, then silently lets a replayed upload through (or double-processes a call) the moment another instance serves the request. Keep that state in a **durable store** — Netlify Blobs or your database — keyed by the upload/request id, and check-and-mark it there. (This is also why the server itself runs stateless, with `sessionIdGenerator: undefined`.)

## Connecting a client

Native remote-MCP support is now the norm; reach for the `mcp-remote` bridge only as a fallback.

- **Claude Code** — `claude mcp add --transport http my-mcp https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`
- **Cursor** — add the server to `mcp.json` with the URL and an `Authorization` header.
- **Claude Desktop / claude.ai** — add a **Custom Connector** (Settings → Connectors). Connectors are OAuth-oriented; for a static-bearer server the `mcp-remote` bridge is the reliable path.
- **Fallback (older / stdio-only clients)** — `npx mcp-remote https://<site>.netlify.app/mcp --header "Authorization: Bearer <token>"`

Full client matrix and the OAuth / Custom Connector deep-dive: [connecting clients](references/connecting-clients.md).

## Local dev and deploy

- **Run it:** `netlify dev` serves the function at `http://localhost:8888/mcp`.
- **Test it:** the MCP Inspector — `npx @modelcontextprotocol/inspector` — connect via Streamable HTTP to your URL with an `Authorization: Bearer` header and list/call tools. Or point `claude mcp add --transport http` at the localhost URL.
- **Identity caveat:** Netlify Identity does **not** work under `netlify dev`, so per-user-key auth must be tested on a deploy preview. See the **netlify-identity** skill.
- **Deploy:** push to Git, or `netlify deploy --build --prod`.
- **Secrets:** set tokens/keys as env vars (`netlify env:set MCP_BEARER_TOKEN <value> --secret`) — never in code.

## Cross-cutting rules

- Never hardcode secrets. Store tokens, API keys, and signing secrets as Netlify environment variables (mark them secret). Beyond the leak risk, a bearer token or signing secret written into source (or any file the build publishes) trips **Netlify's secrets scanning and fails the deploy** even after an otherwise-green build — the fix is to move it to a secret env var and read it at runtime with `Netlify.env.get(...)`, and rotate the token if it was committed, *not* to disable the scanner. See **netlify-deploy** for the scan controls.
- Inside functions, read env vars with `Netlify.env.get("VAR")`, not `process.env`.
- Add `.netlify` to `.gitignore`.

## Related skills and references

- [authentication](references/authentication.md) — single-secret vs per-user API keys (Identity) in depth.
- [connecting clients](references/connecting-clients.md) — full client matrix, OAuth, and Custom Connectors.
- [file uploads](references/file-uploads.md) — letting an agent upload images/files via presigned URLs to Netlify Blobs.
- **netlify-functions** — function syntax, routing, limits. **netlify-identity** — Identity setup. **netlify-database** / **netlify-blobs** — where to store keys and files. **netlify-deploy** — deploys. **netlify-config** — env vars.

Referenced files: 3

Package details

Publisher declarations from the archived package. These are separate from our research and the live service's terms.

Package license
MIT
Package author
Netlify
Keywords
See publisher keywords

Declared capabilities

  • Read
  • Write

Package observed Oct 7, 2026.

Technical details
First seen
Sep 30, 2026 · 22:02 UTC
Last seen
Oct 7, 2026 · 00:00 UTC
Latest observed change
Oct 7, 2026 · 00:02 UTC
Collection status
Collected

plugin_asdk_app_691f1f8f72408191afdbbdf8242bdf86

Download plugin data (JSON)

Before you connect Netlify

How do I connect it?

Open the publisher's marketplace listing to check current availability and follow its connection instructions. This directory does not install plugins. Check the requested access and any account requirements before connecting.

Check marketplace availability ↗

Does it require paid access?

We have not established the pricing or subscription requirements for this plugin. An absent price does not mean free access.

Compare researched pricing and access models →

How can I evaluate it?

Check the declared skills and available files, then try a small task whose result you can verify. Our archived descriptions and instructions establish publisher claims, not tested runtime quality. Review sources and coverage limits.