← All posts
·15 min read

Claude Code with Encore: Typed Backends With Infra as Code

Claude CodeEncoreBackendTypeScript
Claude Code with Encore: TypeScript backend with infrastructure as code

Why Encore without CLAUDE.md produces brittle services

Encore is the most opinionated production backend framework available to TypeScript developers in 2026. The framework infers infrastructure (databases, message queues, cron jobs, secrets, service-to-service auth) from declarations in code, then provisions it across environments without separate Terraform or YAML. The opinionation is the strength. It is also the failure mode when Claude Code touches it. Without explicit constraints, Claude generates services that import from each other directly instead of using Encore's service-to-service RPCs, hardcodes secrets that should be declared with secret(), writes migration files in the wrong naming format so Encore skips them, and uses raw process.env reads that work locally but fail in deployed environments.

The most common Claude defaults that hurt Encore projects: defining endpoints with plain Express-style handlers instead of api() from encore.dev/api, putting all services in one giant folder instead of one folder per service with its own encore.service.ts, calling another service's internal functions directly (which works in development but breaks the infrastructure boundary Encore needs for deployment), naming SQL migrations with timestamps or 001_init.sql instead of the required 1_init.up.sql format, and using setInterval for scheduled tasks instead of declaring a cron job that Encore can provision.

This guide covers the CLAUDE.md configuration that locks Claude Code into Encore's correct model: one service per folder with a service.ts file, every endpoint declared with api(), every secret through the secret() builder, every migration in the up-suffixed format, and every cron and pub/sub declared inline so Encore can provision them. If you are building the frontend that calls these services, Claude Code with Next.js covers the type-safe client generation pattern. For TypeScript-specific configuration, Claude Code with TypeScript shows the strict mode setup that pairs with Encore's inferred types.

The Encore CLAUDE.md template

The CLAUDE.md at your project root is read at the start of every Claude Code session. For an Encore project it needs to declare: the SDK version, the service boundary policy, the endpoint declaration pattern, the secrets and database access policy, the migration naming convention, and the hard rules that block the mistakes Claude makes most often.

# Encore rules

## Stack
- encore.dev ^1.40, TypeScript 5.x strict
- Node.js 20.x LTS, encore CLI installed globally
- Postgres for databases (provisioned by Encore)
- NATS or GCP Pub/Sub for messaging (provisioned by Encore)

## Project structure
- One service per folder under src/
- Each service folder has: encore.service.ts (declares the service)
- Each endpoint file: <service>/<endpoint>.ts with api() export
- Shared types (cross-service request/response shapes): src/shared/types.ts
- NEVER import directly between service folders, use service clients

## Service declaration (MANDATORY)
Every service folder MUST contain encore.service.ts:

  import { Service } from "encore.dev/service";
  export default new Service("service-name");

The service name is the kebab-case identifier used in URLs and logs.

## Endpoint declaration (MANDATORY)
Every endpoint MUST use api() from encore.dev/api:

  import { api } from "encore.dev/api";

  interface CreateUserRequest { name: string; email: string; }
  interface CreateUserResponse { id: string; }

  export const createUser = api(
    { method: "POST", path: "/users", expose: true },
    async (req: CreateUserRequest): Promise<CreateUserResponse> => {
      // implementation
    }
  );

- expose: true for public endpoints (auth still applies)
- expose: false (default) for internal service-to-service calls
- auth: true on the api() options when authentication is required

## Service-to-service calls (HARD RULE)
- NEVER import handler functions from another service folder
- ALWAYS import the service client: import { users } from "~encore/clients"
- Call as: const result = await users.createUser({ name, email })
- The client is generated from the api() declarations, fully typed

## Secrets (HARD RULE)
- NEVER use process.env.<NAME> for secrets in service code
- ALWAYS declare with: const apiKey = secret("StripeSecretKey")
- Reference as: apiKey() inside endpoint bodies
- Set values per environment: encore secret set --type production StripeSecretKey

## Database access
- Provision: const db = new SQLDatabase("users", { migrations: "./migrations" })
- Migrations folder: <service>/migrations/<n>_<name>.up.sql (e.g. 1_init.up.sql)
- NEVER use timestamps or 001/002/003 padded prefixes, Encore expects plain integers
- Query: await db.query`SELECT * FROM users WHERE id = ${id}` (tagged template)
- NEVER concatenate strings into raw SQL, use the tagged template form

## Hard rules
- NEVER use express, fastify, or other HTTP frameworks alongside Encore
- NEVER use setInterval / setTimeout for scheduled work, declare a CronJob
- NEVER hardcode secrets, environment URLs, or service hostnames
- ALWAYS run encore gen client TypeScript to produce frontend types
- ALWAYS test with encore test, not vanilla jest/vitest invocation

Four rules here prevent the majority of production failures Claude generates without them.

The service-to-service via clients rule is the most consequential for architecture. Encore's deployment model places each service on its own runtime. Internal imports across service folders work in local development because everything runs in one process, but fail in production where services run separately. The auto-generated ~encore/clients module provides typed RPC wrappers that work across both. Claude defaults to direct imports because they look cleaner. The CLAUDE.md rule prevents the silent break at deployment time.

The secrets through secret() builder rule prevents production credential leaks. Encore's secret() builder reads from the encrypted per-environment secret store. process.env only works in the local development environment. A service that hardcodes process.env.STRIPE_SECRET_KEY runs fine locally where you have a .env file, then crashes in production with undefined because Encore does not inject environment variables that were not declared as secrets.

The migration naming rule prevents a particularly frustrating debugging session. Encore expects migrations as <integer>_<name>.up.sql in the service's migrations/ folder. A common Claude default is to copy a Knex or TypeORM naming style with timestamps (20260531120000_init.sql) or padded numbers (001_init.sql). Encore silently skips files that do not match its pattern, so the migration never runs, the table never exists, and the first query fails with a confusing schema error.

Installation and project setup

Install the Encore CLI globally:

brew install encoredev/tap/encore
encore version

Create a new app:

encore app create claudify-backend --example=ts/empty
cd claudify-backend

The minimal project layout that the CLAUDE.md template expects:

src/
  users/
    encore.service.ts
    create-user.ts
    get-user.ts
    migrations/
      1_init.up.sql
  billing/
    encore.service.ts
    create-invoice.ts
    migrations/
      1_init.up.sql
  shared/
    types.ts
package.json
encore.app
tsconfig.json

Each service folder declares itself with a service.ts:

// src/users/encore.service.ts
import { Service } from "encore.dev/service";

export default new Service("users");

The service name ("users") is what other services use to reference this one. It appears in metrics, traces, the Encore developer dashboard, and the generated client module.

Endpoint declaration with api()

The endpoint pattern that produces a typed, observable, routable HTTP handler:

// src/users/create-user.ts
import { api, APIError } from "encore.dev/api";
import { db } from "./db";

interface CreateUserRequest {
  name: string;
  email: string;
}

interface CreateUserResponse {
  id: string;
  createdAt: string;
}

export const createUser = api(
  { method: "POST", path: "/users", expose: true },
  async (req: CreateUserRequest): Promise<CreateUserResponse> => {
    if (!req.email.includes("@")) {
      throw APIError.invalidArgument("email is not a valid address");
    }

    const result = await db.query<{ id: string; created_at: Date }>`
      INSERT INTO users (name, email)
      VALUES (${req.name}, ${req.email})
      RETURNING id, created_at
    `;

    const row = result.rows[0];
    if (!row) {
      throw APIError.internal("failed to create user");
    }

    return {
      id: row.id,
      createdAt: row.created_at.toISOString(),
    };
  }
);

Things to notice.

The first argument to api() is the route metadata. method: "POST" and path: "/users" define the HTTP route. expose: true makes the endpoint reachable from outside the cluster. The default expose: false makes the endpoint internal-only, callable by other services through the generated client.

The second argument is the typed handler. The request and response types are full TypeScript interfaces. Encore validates incoming requests against the request type at runtime and generates frontend client types from the response type.

Errors use APIError from encore.dev/api. Each kind maps to an HTTP status code: invalidArgument is 400, unauthenticated is 401, permissionDenied is 403, notFound is 404, internal is 500. Claude often throws plain Error objects, which Encore converts to 500 by default. Using APIError explicitly produces correct status codes and lets the client distinguish between user errors and server errors.

Add an endpoint section to CLAUDE.md:

## Endpoint patterns

- Every endpoint file exports a const named like the operation: createUser, getUser
- Use api() from encore.dev/api as the wrapper
- First arg: { method, path, expose, auth } options
- Second arg: async handler with explicit request/response types
- Errors: import APIError, throw APIError.<kind>(message)
- NEVER throw plain Error in api() handlers
- NEVER return raw values, always match the declared response type

Service-to-service calls via generated clients

The internal client generation is the heart of Encore's service boundary model. When you declare an api() endpoint in service A and another service B needs to call it, you do not import from A's source. You import from ~encore/clients:

// src/billing/create-invoice.ts
import { api, APIError } from "encore.dev/api";
import { users } from "~encore/clients";

interface CreateInvoiceRequest {
  userId: string;
  amountCents: number;
}

interface CreateInvoiceResponse {
  invoiceId: string;
}

export const createInvoice = api(
  { method: "POST", path: "/invoices", expose: true },
  async (req: CreateInvoiceRequest): Promise<CreateInvoiceResponse> => {
    // Fetch the user from the users service via the generated client
    const user = await users.getUser({ id: req.userId });
    if (!user) {
      throw APIError.notFound("user not found");
    }

    // Create the invoice (omitted)
    const invoiceId = await persistInvoice(user.email, req.amountCents);

    return { invoiceId };
  }
);

The users import comes from ~encore/clients, a virtual module that Encore's TypeScript plugin generates from the api() declarations in the users service. The types match exactly. Refactoring an endpoint in users produces a TypeScript error in every caller until they are updated.

In local development, Encore routes the call to the in-process function for speed. In deployed environments, the same call goes over HTTP between service instances. The handler code does not change. Claude often imports import { getUser } from "../users/get-user" directly, which works locally because everything runs in one Node process but fails when the services deploy to separate runtimes.

Add a service client section to CLAUDE.md:

## Service-to-service calls

- Internal clients: import { <service> } from "~encore/clients"
- The client module is generated from api() declarations
- Call as: await <service>.<endpoint>({ ...request })
- TypeScript validates the request and response types end-to-end
- NEVER import handler functions across service folders
- For circular dependency safety: services can call each other's clients freely

Secrets with secret()

Encore's secret management replaces both .env files and external secret stores like Vault. You declare a secret in code, then set its value per environment from the CLI:

// src/billing/stripe-client.ts
import { secret } from "encore.dev/config";
import Stripe from "stripe";

const stripeSecretKey = secret("StripeSecretKey");

export function getStripe(): Stripe {
  return new Stripe(stripeSecretKey(), {
    apiVersion: "2026-04-10",
  });
}

The secret() builder returns a function that resolves the secret value at call time. The lazy resolution means Encore can inject the production value into the binary at deploy time without bundling it during build.

Set values per environment from the CLI:

# Local development
encore secret set --type local StripeSecretKey
# Prompts for the value, stores in encrypted local cache

# Preview environments (PR builds)
encore secret set --type preview StripeSecretKey

# Production
encore secret set --type production StripeSecretKey

Add a secrets section to CLAUDE.md:

## Secrets

- Declare with: const myKey = secret("MyKeyName")
- Reference as: myKey() inside function bodies
- Set per environment: encore secret set --type <env> <name>
- Environments: local, preview, development, production
- NEVER use process.env for secret values in service code
- NEVER commit .env files to git, Encore replaces them entirely
- The secret name (PascalCase) must match across declaration and CLI set

For Stripe-specific patterns that pair with Encore's secret management, Claude Code with Stripe covers the webhook handler patterns that read the same secret. For the broader environment variable approach, Claude Code environment variables shows the general pattern.

Databases and migrations

Encore provisions a Postgres database per service when you declare one:

// src/users/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";

export const db = new SQLDatabase("users", {
  migrations: "./migrations",
});

The database name ("users") becomes the physical database in production. The migrations path points to a folder of SQL files that Encore applies in order.

The migration naming convention is strict: <integer>_<name>.up.sql:

-- src/users/migrations/1_init.up.sql
CREATE TABLE users (
  id           UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email        TEXT NOT NULL UNIQUE,
  name         TEXT NOT NULL,
  created_at   TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX idx_users_email ON users (email);
-- src/users/migrations/2_add_role.up.sql
ALTER TABLE users
ADD COLUMN role TEXT NOT NULL DEFAULT 'member';

The plain integer prefix is non-negotiable. Encore parses the leading digits as the migration version. 001_init.up.sql, 20260531_init.up.sql, and init.sql are all skipped silently. Claude defaults to padded numbers or timestamps because that is what most ORMs use. The CLAUDE.md rule prevents the wasted hour debugging why the users table does not exist.

Querying uses tagged templates that parameterise safely:

// src/users/get-user.ts
import { api, APIError } from "encore.dev/api";
import { db } from "./db";

interface GetUserRequest {
  id: string;
}

interface User {
  id: string;
  email: string;
  name: string;
  createdAt: string;
}

export const getUser = api(
  { method: "GET", path: "/users/:id" },
  async ({ id }: GetUserRequest): Promise<User | null> => {
    const result = await db.query<{
      id: string;
      email: string;
      name: string;
      created_at: Date;
    }>`
      SELECT id, email, name, created_at FROM users WHERE id = ${id}
    `;

    const row = result.rows[0];
    if (!row) return null;

    return {
      id: row.id,
      email: row.email,
      name: row.name,
      createdAt: row.created_at.toISOString(),
    };
  }
);

The ${id} interpolation looks like string concatenation but Encore translates it into a parameterised query. SQL injection is not possible through this form. Claude sometimes generates raw string concatenation (db.query("SELECT ... WHERE id = '" + id + "'")), which Encore allows but defeats the safety. The CLAUDE.md rule that all queries use the tagged template form prevents this.

Pub/sub topics and subscriptions

Encore provisions a message queue when you declare a topic. The pattern is identical across NATS (local) and GCP Pub/Sub (production):

// src/shared/topics.ts
import { Topic } from "encore.dev/pubsub";

export interface UserCreated {
  userId: string;
  email: string;
  createdAt: string;
}

export const userCreated = new Topic<UserCreated>("user-created", {
  deliveryGuarantee: "at-least-once",
});

Publishing from the user creation endpoint:

// src/users/create-user.ts
import { userCreated } from "../shared/topics";

// inside the endpoint handler:
await userCreated.publish({
  userId: row.id,
  email: req.email,
  createdAt: row.created_at.toISOString(),
});

Subscribing from another service:

// src/billing/subscribers.ts
import { Subscription } from "encore.dev/pubsub";
import { userCreated } from "../shared/topics";

new Subscription(userCreated, "send-welcome-invoice-prep", {
  handler: async (event) => {
    // event is typed as UserCreated
    await prepareWelcomeInvoice(event.userId, event.email);
  },
});

Encore provisions the topic and the subscription consumer queues without separate configuration. The at-least-once delivery guarantee means handlers may receive duplicates and must be idempotent. Claude often subscribes without considering idempotency. Add an idempotency note to CLAUDE.md and Claude will generate handlers that check for prior processing.

## Pub/sub

- Topics: const t = new Topic<EventType>("name", { deliveryGuarantee: "at-least-once" })
- Topic event types live in src/shared/topics.ts, exported alongside the topic
- Subscriptions: new Subscription(topic, "subscriber-id", { handler })
- Handlers MUST be idempotent (delivery is at-least-once, duplicates expected)
- Check for prior processing via a unique key in the event before mutating state
- Subscriber IDs are stable across deploys, NEVER rename them

Cron jobs

Scheduled work uses CronJob instead of setInterval. The schedule is part of the declaration so Encore can provision it on the platform:

// src/billing/cron.ts
import { CronJob } from "encore.dev/cron";
import { api } from "encore.dev/api";

// The endpoint that the cron job calls
const reconcileInvoices = api(
  { method: "POST", path: "/cron/reconcile-invoices", expose: false },
  async (): Promise<void> => {
    // implementation
  }
);

// The cron schedule
const _ = new CronJob("reconcile-invoices-daily", {
  title: "Reconcile invoices with Stripe",
  every: "24h",
  endpoint: reconcileInvoices,
});

The every: "24h" is the simplest form. Encore also accepts a cron expression via the schedule: field for more complex timing. The endpoint that the cron job calls is a normal api() declaration, just with expose: false so it is only reachable from the cron scheduler.

Claude defaults to setInterval because that is what tutorial code uses. setInterval works in local development but does not survive process restarts, does not run across multiple instances, and does not appear in the Encore developer dashboard's cron schedule view. The CronJob declaration solves all three.

Common Claude Code mistakes with Encore

Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.

1. Direct cross-service imports

Claude generates: import { getUser } from "../users/get-user" from a billing handler.

Correct pattern: import { users } from "~encore/clients" and await users.getUser({ id }).

2. process.env for secrets

Claude generates: const stripeKey = process.env.STRIPE_SECRET_KEY inside a service.

Correct pattern: const stripeKey = secret("StripeSecretKey") and call as stripeKey().

3. Wrong migration naming

Claude generates: 001_init.sql or 20260531_init.sql in the migrations folder.

Correct pattern: 1_init.up.sql, 2_add_role.up.sql, with plain integer prefixes and the .up.sql suffix.

4. Plain Error throws

Claude generates: throw new Error("user not found") inside an api() handler.

Correct pattern: throw APIError.notFound("user not found") using the typed error constructors.

5. setInterval for cron

Claude generates: setInterval(reconcile, 24 * 60 * 60 * 1000) somewhere at module load.

Correct pattern: new CronJob("reconcile-daily", { every: "24h", endpoint: reconcile }).

6. Express alongside Encore

Claude generates: import express from "express" and creates a parallel Express app for some endpoints.

Correct pattern: every HTTP endpoint goes through api(). Encore replaces Express entirely.

Add a common mistakes section to CLAUDE.md with these six pairs. Claude benefits from explicit before/after comparisons because it can match the pattern it would otherwise generate to the corrected form.

Permission hooks for Encore scripts

An Encore project accumulates scripts: local dev runs, type generation, database migrations, deploys to preview and production. Some are read-only. Some deploy to production or destroy infrastructure. Permission hooks gate the destructive ones.

In .claude/settings.local.json:

{
  "permissions": {
    "allow": [
      "Bash(encore run*)",
      "Bash(encore gen client typescript*)",
      "Bash(encore db shell*)",
      "Bash(encore secret list*)"
    ],
    "deny": [
      "Bash(encore deploy*)",
      "Bash(encore env destroy*)",
      "Bash(encore secret unset*)",
      "Bash(encore db reset*)"
    ]
  }
}

Running locally, generating client code, opening database shells, and listing secrets are safe. Deploying to any environment, destroying environments, removing secrets, and resetting databases require explicit confirmation. The deny list forces Claude to surface those operations as prompts rather than running them as part of an automated workflow.

Building Encore services that deploy without surprises

The Encore CLAUDE.md in this guide produces services where every endpoint goes through api(), every cross-service call uses the generated client, every secret is declared with secret() and resolved per environment, every migration follows the integer-prefix naming convention, and every scheduled job uses CronJob instead of setInterval.

The underlying principle is the same as any opinionated framework with Claude Code. Encore without a CLAUDE.md produces code that runs locally because the dev runtime is forgiving, then breaks on the first preview deploy because cross-service imports collapse, secrets are undefined, and migrations were silently skipped. The CLAUDE.md template removes each failure mode by making the correct pattern the only pattern Claude can generate.

For the frontend that consumes these services, Claude Code with Next.js covers the typed client integration, and Claudify includes an Encore-specific CLAUDE.md template with the service boundary rules, secret patterns, migration naming, and all six common-mistake rules pre-configured.

Get Claudify. A complete Claude Code operating system with backend framework templates that match how production teams actually ship.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir Claudify - Featured on Startup Fame