← All posts
·18 min read

Claude Code with Netlify: Deploy Config, Edge Functions, Identity

Claude CodeNetlifyDeploymentWorkflow
Claude Code with Netlify: Deploy Config, Edge Functions, Identity

Why Netlify projects need a Claude Code config

Netlify is the second-most-used JAMstack deployment platform, and it is opinionated about configuration in ways that Vercel users do not always expect. The build config lives in netlify.toml, redirects can live in two places (netlify.toml or _redirects), environment variables scope differently across deploy contexts, and the platform offers two distinct function runtimes with different filesystem conventions.

Claude Code knows Netlify. It knows netlify.toml, the Netlify CLI, edge functions, serverless functions, and deploy previews. What it cannot infer is your project: which framework you use, which runtime your functions should target, how your env vars are named, which deploy contexts need which values, and whether you want Netlify Forms or Identity or both.

Without that context in a CLAUDE.md, Claude generates configuration that builds locally and breaks on Netlify. It conflates edge functions with serverless functions. It writes _redirects when netlify.toml already has a redirects block. It targets a Node version the Netlify build image does not have. Every one of these is a silent failure until the deploy log appears.

This guide covers the CLAUDE.md template, netlify.toml patterns, function runtime decisions, env var workflow, Forms and Identity setup, and deploy preview habits that produce consistent shipping. If you are new to Claude Code, the Claude Code setup guide covers installation before any of this applies.

The Netlify CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For a Netlify project it needs to declare: framework, Node version, build commands, publish directory, function directories, environment variable conventions, the deploy contexts in use, and the hard rules that prevent destructive operations.

# Netlify project rules

## Stack
- Framework: Astro 4.x (static output, @astrojs/netlify adapter)
  OR: Next.js 15 (App Router, @netlify/plugin-nextjs)
  OR: plain HTML/JS in public/ (no framework)
- Node: 20.x (matches Netlify build image node:20)
- Package manager: pnpm 9.x (lockfile committed, do not switch to npm)
- Deployment target: Netlify

## netlify.toml location: project root (required)

## Build settings
[build]
  command = "pnpm build"
  publish = "dist"         # Astro: dist/, Next.js: .next/, plain: public/
  functions = "netlify/functions"
  edge_functions = "netlify/edge-functions"

[build.environment]
  NODE_VERSION = "20"
  PNPM_VERSION = "9"

## Deploy contexts in use
- production: main branch, full build
- deploy-preview: all PRs and non-production branches
- branch-deploy: named branches (staging, canary), explicit configuration required

## Function conventions
- Serverless functions: netlify/functions/*.mts (TypeScript, CommonJS output)
- Edge functions: netlify/edge-functions/*.ts (Deno-compatible, no Node built-ins)
- Function names match their file names (no config needed for discovery)

## Env var rules
- All vars listed in .env.example with a comment
- Netlify UI or Netlify CLI for all additions (never hardcode)
- Build-time vars: set in [build.environment] or via Netlify UI (Production + Preview)
- Runtime vars (functions): set via Netlify UI only, not in netlify.toml
- NEVER commit real values to netlify.toml or .env files

## Hard rules
- NEVER run `netlify deploy --prod` without explicit user request
- NEVER delete or rewrite netlify.toml wholesale, edit specific sections only
- NEVER mix _redirects file and netlify.toml redirects block on the same project
- NEVER use __redirects (wrong name), file must be exactly _redirects
- NEVER add a new env var without updating .env.example
- NEVER use Node built-in modules in edge functions (no fs, path, crypto, etc.)
- NEVER call `netlify env:set` for production scope without confirming with user

Four rules carry the most weight.

The _redirects vs netlify.toml rule stops Claude from generating both. Netlify processes redirects from both files but gives netlify.toml priority. Duplicate rules do not merge cleanly. Pick one source of truth and stick to it. For most projects, netlify.toml is the right choice because it keeps all configuration in one place. The _redirects file remains useful for plain HTML sites where a TOML file feels like overhead.

The edge function Node built-ins rule is the equivalent of Vercel's edge runtime restriction. Netlify edge functions run on Deno, not Node. No require, no fs, no Buffer, no npm packages that depend on Node internals. Claude sometimes generates an edge function that imports a Node-only package because the fetch patterns look similar to serverless functions. The rule catches that before it reaches a broken deploy.

The NODE_VERSION rule is specific to Netlify. Netlify build images ship with multiple Node versions but require an explicit pin. Without it, Netlify picks its default, which may not match your local Node. Claude should always set NODE_VERSION in [build.environment] rather than relying on the platform default.

The deploy command rule prevents accidental production pushes. Claude has terminal access and can run netlify deploy --prod if asked broadly to "push to production". The hard rule makes the confirmation explicit.

netlify.toml: structure, headers, redirects, plugins

netlify.toml is the single configuration file Netlify reads at build time. It controls the build command, publish directory, function locations, headers, redirects, and plugins. A well-structured netlify.toml is stable across sessions because Claude can edit specific sections without touching others.

A typical configuration for an Astro static site with serverless functions and deploy previews:

[build]
  command = "pnpm build"
  publish = "dist"
  functions = "netlify/functions"
  edge_functions = "netlify/edge-functions"

[build.environment]
  NODE_VERSION = "20"
  PNPM_VERSION = "9"

# Production-specific build
[context.production]
  command = "pnpm build"

# Preview builds disable analytics and use a test API key
[context.deploy-preview]
  command = "pnpm build"

[context.deploy-preview.environment]
  ANALYTICS_ENABLED = "false"
  STRIPE_SECRET_KEY = "sk_test_placeholder"

# Security headers applied to all responses
[[headers]]
  for = "/*"
  [headers.values]
    X-Frame-Options = "DENY"
    X-Content-Type-Options = "nosniff"
    Referrer-Policy = "strict-origin-when-cross-origin"
    Permissions-Policy = "camera=(), microphone=(), geolocation=()"

# Long cache for immutable assets
[[headers]]
  for = "/assets/*"
  [headers.values]
    Cache-Control = "public, max-age=31536000, immutable"

# SPA fallback (or Next.js / Astro SSR)
[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

# Netlify plugin for Next.js (remove if not using Next.js)
[[plugins]]
  package = "@netlify/plugin-nextjs"

The [context.*] blocks are the key Netlify concept that Claude needs written out. Each deploy context gets its own configuration block. Production and preview contexts can have different build commands, different environment variable values, and different plugins. Without these blocks, Claude sometimes generates a single flat config and wonders why the preview build does not suppress analytics.

The [[headers]] block uses TOML array-of-tables syntax. Claude sometimes writes [headers] (single bracket), which is wrong and silently ignored. The double bracket [[headers]] creates a new entry in the headers array each time. The rule in CLAUDE.md against wholesale rewriting of netlify.toml preserves these blocks across edits.

The [[redirects]] catch-all rule is the SPA fallback. For React Router, Vue Router, or Svelte SPA apps, all routes need to fall back to index.html at status 200 so the client-side router handles navigation. Without it, refreshing any non-root route returns a 404. For Astro SSR and Next.js on Netlify, the adapter handles routing and this rule should be removed.

The [[plugins]] block wires Netlify's build plugins. For Next.js, @netlify/plugin-nextjs is required. For image optimization, @netlify/plugin-image-optim is available. Claude should install the npm package and add the plugin block together, never one without the other.

For framework-side conventions that sit alongside this Netlify config, the Claude Code with Astro guide covers Astro-specific build and content patterns. The Claude Code with Next.js guide covers the App Router conventions that @netlify/plugin-nextjs builds on.

Edge functions vs serverless functions

This is the decision that shapes your function architecture, and Claude cannot make it without guidance. The two runtimes are meaningfully different.

Netlify serverless functions run in a Node.js Lambda environment. They have access to the full Node API, npm packages including database drivers, the Anthropic SDK, and image processing libraries. Cold starts are 100 to 800 milliseconds. Execution is capped at 26 seconds by default (10 seconds for background functions on the Starter plan). Files live in netlify/functions/.

Netlify edge functions run at the network edge on Deno. They have access to the Web Platform API (fetch, Request, Response, URL, crypto.subtle). They do not have access to npm packages that depend on Node internals. Cold starts are under 5 milliseconds globally. Execution is capped at 30 seconds wall time. Files live in netlify/edge-functions/.

Add a function decision rule to CLAUDE.md:

## Function runtime decision tree

Use edge functions when ALL of the following are true:
- Response is generated from request data + a fast network call
- No npm packages with Node built-in dependencies
- No filesystem access required
- Global low latency is more important than full Node API access
- Use cases: A/B routing, geolocation, auth token validation, header rewrites

Use serverless functions when ANY of the following are true:
- Database query needing a native Node driver (Postgres, MySQL, SQLite)
- LLM call via Anthropic or OpenAI SDK
- Image processing, PDF generation, or binary manipulation
- File uploads to S3 / Cloudflare R2 / local tmp
- Total response time may exceed 25 seconds (use background: true)

Default: serverless function in netlify/functions/

An edge function that validates an auth token and rewrites a request header:

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

export default async function handler(request: Request, context: Context) {
  const token = request.headers.get("authorization")?.replace("Bearer ", "");

  if (!token) {
    return new Response("Unauthorized", { status: 401 });
  }

  // Lightweight validation: no DB, no Node built-ins
  const payload = await verifyJwt(token);
  if (!payload) {
    return new Response("Invalid token", { status: 401 });
  }

  // Enrich request headers for the downstream serverless function
  const req = new Request(request);
  req.headers.set("x-user-id", payload.sub);
  req.headers.set("x-user-role", payload.role);

  return context.next(req);
}

export const config: Config = {
  path: "/api/*",
  excludedPath: "/api/public/*",
};

async function verifyJwt(token: string): Promise<{ sub: string; role: string } | null> {
  // Use crypto.subtle (available in Deno edge runtime)
  try {
    const [header, payload] = token.split(".").slice(0, 2);
    if (!header || !payload) return null;
    const decoded = JSON.parse(atob(payload));
    return { sub: decoded.sub, role: decoded.role ?? "member" };
  } catch {
    return null;
  }
}

A serverless function that queries Postgres and calls an LLM:

// netlify/functions/generate-summary.mts
import type { Handler, HandlerEvent } from "@netlify/functions";
import Anthropic from "@anthropic-ai/sdk";
import postgres from "postgres";

const sql = postgres(process.env.DATABASE_URL!);
const client = new Anthropic();

export const handler: Handler = async (event: HandlerEvent) => {
  if (event.httpMethod !== "POST") {
    return { statusCode: 405, body: "Method Not Allowed" };
  }

  const { recordId } = JSON.parse(event.body ?? "{}");

  const [record] = await sql`
    SELECT content FROM posts WHERE id = ${recordId} LIMIT 1
  `;

  if (!record) {
    return { statusCode: 404, body: "Not found" };
  }

  const response = await client.messages.create({
    model: "claude-opus-4-7",
    max_tokens: 512,
    messages: [{ role: "user", content: `Summarise this in two sentences: ${record.content}` }],
  });

  const summary = response.content[0].type === "text" ? response.content[0].text : "";

  return {
    statusCode: 200,
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ summary }),
  };
};

The .mts extension for serverless functions is intentional. Netlify compiles TypeScript function files natively and expects ESM-compatible output. The .mts extension forces TypeScript to emit ES module output. Using .ts can produce CommonJS output that fails at runtime if your package.json declares "type": "module".

For alternative edge runtime comparisons, the Claude Code with Cloudflare Workers guide covers the Workers runtime, which shares the Deno-adjacent model and is worth reading alongside this.

Environment variables across deploy contexts

Netlify's env var model has four layers: build-time variables set in netlify.toml, variables set via the Netlify UI or CLI scoped to specific contexts, variables injected automatically by the Netlify build system, and secrets that should never appear in netlify.toml at all. Claude needs the distinction made explicit.

## Env var classification

### Build-time only (safe in netlify.toml [build.environment])
- NODE_VERSION, PNPM_VERSION, NEXT_TELEMETRY_DISABLED
- Non-secret feature flags: ANALYTICS_ENABLED, FEATURE_NEW_CHECKOUT

### Context-scoped via Netlify UI (never in netlify.toml)
- API keys (Stripe, Anthropic, Resend, database URLs)
- Set scope: Production, Deploy preview, or Branch deploy
- Preview context gets a test key, production gets live key

### Auto-injected by Netlify (read-only, always available)
- CONTEXT: production | deploy-preview | branch-deploy
- DEPLOY_URL: current deploy URL (useful for absolute redirect URLs)
- DEPLOY_ID: unique identifier for this build
- BRANCH: current git branch name
- COMMIT_REF: git commit SHA

### Hard rules
- NEVER put real API keys in netlify.toml
- NEVER put real API keys in .env files committed to git
- ALWAYS use CONTEXT or DEPLOY_URL for environment-aware behaviour
- ADD every new var to .env.example with a description before adding to Netlify UI

The CONTEXT variable is the Netlify equivalent of NODE_ENV. Use it to drive environment-aware behaviour inside functions and the build:

// netlify/functions/api.mts
const isProduction = process.env.CONTEXT === "production";
const stripeKey = process.env.STRIPE_SECRET_KEY; // Set to live key in production, test key in preview

// netlify/edge-functions/analytics.ts
export default async function handler(request: Request, context: Context) {
  const isPreview = Netlify.env.get("CONTEXT") !== "production";
  if (isPreview) {
    return context.next(); // Skip analytics in non-production
  }
  // ... analytics logic
}

The DEPLOY_URL variable is useful for constructing absolute callback URLs that work correctly in deploy previews without hardcoding a domain:

const baseUrl = process.env.DEPLOY_URL ?? process.env.URL ?? "http://localhost:8888";
const callbackUrl = `${baseUrl}/api/auth/callback`;

For secrets workflow patterns that apply across hosts, the Claude Code environment variables guide covers .env.example discipline and the mental model for separating build-time from runtime secrets.

Netlify Forms and Identity

Netlify Forms and Netlify Identity are platform features that Claude commonly misuses because they depend on HTML attributes and build-time processing rather than API calls. A CLAUDE.md entry for each prevents the most common failures.

Netlify Forms

Netlify Forms are detected at deploy time. Netlify scans your built HTML for the data-netlify="true" attribute, registers the form, and starts collecting submissions. The form does not need a backend function for basic submission handling. Claude often generates a serverless function for form handling when it is not needed, and equally often forgets the data-netlify attribute entirely.

## Netlify Forms rules

### HTML form setup (static sites)
- Must include data-netlify="true" on the <form> element
- Must include <input type="hidden" name="form-name" value="your-form-name">
- Form must exist in the built HTML (not injected by client-side JS after load)
- Form name must be unique across the site

### Canonical pattern
<form name="contact" method="POST" data-netlify="true" data-netlify-honeypot="bot-field">
  <input type="hidden" name="form-name" value="contact">
  <input type="hidden" name="bot-field" aria-hidden="true">
  <label for="email">Email</label>
  <input type="email" id="email" name="email" required>
  <label for="message">Message</label>
  <textarea id="message" name="message" required></textarea>
  <button type="submit">Send</button>
</form>

### React / Astro component forms
- Server-side render the form so Netlify can detect it at build time
- OR fetch from client-side and POST with Content-Type: application/x-www-form-urlencoded
  and include form-name as a body field

### Client-side fetch pattern (React SPA)
const handleSubmit = async (e) => {
  e.preventDefault();
  await fetch("/", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      "form-name": "contact",
      email: formData.email,
      message: formData.message,
    }).toString(),
  });
};

### NEVER
- NEVER rely on a dynamically injected form for detection (Netlify does not run JS at scan time)
- NEVER omit the form-name hidden field on client-side POST submissions
- NEVER use multipart/form-data on client-side fetches (use URL-encoded only)

The build-time detection constraint is the most surprising Netlify behaviour for developers coming from other platforms. On Netlify, if a React app renders a form entirely client-side with no server-side shell, Netlify never sees the data-netlify attribute and never registers the form. The fix is either a static shell in the HTML that React hydrates, or the client-side fetch pattern in the CLAUDE.md above.

Netlify Identity

Netlify Identity is a JWT-based auth service built into the platform. It covers sign-up, login, password reset, and OAuth (Google, GitHub) with no backend code. It uses the netlify-identity-widget for the UI layer. Claude sometimes conflates Identity with custom auth and generates API routes that duplicate what the widget already handles.

## Netlify Identity rules

### When to use
- Simple auth for static sites with no custom user schema requirements
- Small user bases where Netlify's 1000-user free tier is sufficient
- Projects already on Netlify with no reason to add a separate auth service

### Setup steps Claude should follow
1. Enable Identity in Netlify site settings (UI, not CLI)
2. Add <script src="https://identity.netlify.com/v1/netlify-identity-widget.js"></script>
3. Add netlify-identity-widget npm package for framework integrations
4. Add Identity invitation redirect in netlify.toml:
   [[redirects]]
     from = "/#confirmation_token=*"
     to = "/confirm"
     status = 200

### JWT verification in serverless functions
import { verify } from "jsonwebtoken";

export const handler = async (event) => {
  const authHeader = event.headers.authorization ?? "";
  const token = authHeader.replace("Bearer ", "");

  if (!token) return { statusCode: 401, body: "Unauthorized" };

  try {
    const payload = verify(token, process.env.JWT_SECRET);
    const userId = payload.sub;
    // proceed with authenticated request
  } catch {
    return { statusCode: 401, body: "Invalid token" };
  }
};

### Hard rules
- NEVER build custom sign-up / login UI if Identity widget covers the use case
- NEVER skip the confirmation_token redirect in netlify.toml (email confirmation breaks)
- ALWAYS verify the JWT in serverless functions before trusting Identity claims
- ALWAYS set the JWT_SECRET env var (found in Netlify UI under Identity settings)

For more complex auth requirements beyond what Identity covers, the Claude Code with NextAuth guide covers a full Auth.js implementation that can sit alongside a Netlify deploy.

Deploy previews, branch deploys, and custom domains

Netlify's deploy preview system is one of its strongest features and one that Claude can actively participate in. Every pull request automatically gets a unique preview URL with full function support and preview-context env vars. Claude can deploy a preview, fetch its URL from the CLI output, and use it to test the build before you merge.

## Deploy workflow

### Local development
netlify dev                          # Start local dev server (mirrors Netlify env)
netlify dev --live                   # Share local dev with a tunnel URL

### Preview deploy (branch or PR)
netlify deploy                       # Deploy current branch to a draft URL
netlify deploy --alias=staging       # Deploy to staging.yourdomain.netlify.app

### Production deploy
ONLY via git push to main            # Netlify git integration triggers production
NEVER: netlify deploy --prod         # Forbidden without explicit user request

### Branch deploys (configured in netlify.toml)
[context.staging]
  command = "pnpm build:staging"

[context.canary]
  command = "pnpm build"

### Custom domain SSL
- Managed via Netlify DNS or external DNS with CNAME/A records
- SSL certificates are automatic (Let's Encrypt via Netlify)
- NEVER manually configure SSL cert files, Netlify handles renewal
- For apex domains: use Netlify DNS (not CNAME) to avoid flattening issues

### Deploy hooks (CI / external trigger)
- Create in Netlify UI: Site settings > Build hooks
- Trigger via POST: curl -X POST -d '{}' "$BUILD_HOOK_URL"
- Store BUILD_HOOK_URL as a secret env var, not in netlify.toml

The netlify dev command is the most underused part of the Netlify CLI. It runs your build locally with the exact environment Netlify would use in production, including function execution, redirects processing, and Identity. Claude should start here when debugging a redirect or function that works locally but breaks on Netlify. Running raw pnpm dev without netlify dev skips the redirects and function layers entirely.

For the full picture of deployment patterns across platforms including branch-based promotion strategies, the Claude Code deploy guide compares the workflows across Netlify, Vercel, Cloudflare, Railway, and Fly.io.

Permission hooks for Netlify projects

The .claude/settings.local.json permission config makes the CLAUDE.md hard rules enforceable. The CLAUDE.md tells Claude what not to do. Permission hooks tell the harness to refuse if Claude tries anyway.

{
  "permissions": {
    "allow": [
      "Bash(pnpm dev*)",
      "Bash(pnpm build*)",
      "Bash(pnpm test*)",
      "Bash(pnpm lint*)",
      "Bash(netlify dev*)",
      "Bash(netlify build*)",
      "Bash(netlify deploy*--alias*)",
      "Bash(netlify env:list*)",
      "Bash(netlify env:get*)",
      "Bash(git push origin feat/*)",
      "Bash(git push origin fix/*)"
    ],
    "deny": [
      "Bash(netlify deploy --prod*)",
      "Bash(netlify deploy*--production*)",
      "Bash(netlify env:set*production*)",
      "Bash(netlify env:unset*production*)",
      "Bash(netlify sites:delete*)",
      "Bash(netlify domain:delete*)",
      "Bash(git push origin main*)",
      "Bash(git push --force*)"
    ]
  }
}

The allow list covers local development, local build mirroring, preview deploys with named aliases, env inspection, and feature-branch pushes. The deny list blocks production deploys, production env mutations, site and domain deletion, force pushes, and direct pushes to main. The broader rationale for permission hooks on deployment projects is covered in Claude Code permissions.

What Claude handles well, what to review

Claude Code generates correct Netlify configuration in most areas when the CLAUDE.md above is in place. netlify.toml edits to specific sections are accurate. Edge function and serverless function boilerplate with correct runtime-appropriate imports is consistent. Netlify Forms HTML with data-netlify and the hidden form-name field is reliable. The netlify dev workflow and netlify deploy preview deploys are things Claude executes correctly.

Four areas warrant manual review.

The first is the [[redirects]] block. Order matters. Netlify processes redirects top-to-bottom and stops at the first match. A catch-all /* rule placed above a specific API proxy rule blocks that proxy silently. Review the order of every redirects block change, especially when Claude inserts a new rule into an existing block.

The second is context-scoped environment variables. Claude can generate the [context.deploy-preview.environment] block in netlify.toml but cannot know which values are safe to express there in plain text and which are secrets. Treat every [context.*.environment] block as a review point: if the value is a real credential, it should not be in netlify.toml regardless of context.

The third is the Netlify Forms build-time detection. If your framework renders forms client-side after hydration, the Netlify build scanner will not detect them. After any form-related change, deploy a preview and manually check that the form appears in the Netlify UI under Forms. A form that is not detected will silently accept POSTs that go nowhere.

The fourth is Identity JWT verification in serverless functions. Claude generates the verification pattern correctly but may use an outdated jsonwebtoken API or miss the JWT_SECRET env var requirement. Verify the secret is set in the Netlify UI (Identity settings), not just in netlify.toml, and that the algorithm in the verification call matches what Netlify Identity uses (HS256).

A configuration that fails closed by default

The Netlify CLAUDE.md template, netlify.toml patterns, function runtime decision tree, context-scoped env var workflow, Forms and Identity setup, and permission hooks in this guide produce a development environment where deploys are quiet, configuration is centralised, function runtime decisions are explicit, and production stays protected.

Six hard rules are worth restating because they cover the failure modes that appear most often without them:

  1. netlify.toml is edited by section, never rewritten wholesale.
  2. _redirects and netlify.toml redirects are never mixed on the same project.
  3. Edge functions do not import Node built-in modules or Node-dependent npm packages.
  4. Every env var lives in .env.example before it lives anywhere else.
  5. Production deploys go through git push to main, never netlify deploy --prod from Claude.
  6. The data-netlify attribute and hidden form-name field are both present on every Netlify Form.

The underlying principle is consistent with every other platform integration: Claude Code produces output at the quality of the context it receives. Without a Netlify-specific CLAUDE.md, Claude omits NODE_VERSION, mixes redirect sources, generates edge functions that import Node modules, and writes deploy commands that touch production directly. With the configuration in this guide, it respects platform constraints, scopes env vars correctly, picks the right function runtime, and ships through preview deploys.

For the mechanics of why CLAUDE.md is the lever that controls Claude output quality, CLAUDE.md explained covers the principles. For the broader stack of Netlify-adjacent tools, the Claude Code with Astro guide and Claude Code with Vercel guide are direct companions. Claudify ships a Netlify-specific CLAUDE.md template and .claude/settings.local.json deny list as part of the Claude Code workflow kit, pre-configured for Astro, Next.js, and plain HTML projects on Netlify.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir