Building Type-Safe Cloud Backend APIs with Encore.ts: From Zero to Production

Your backend framework should handle infrastructure, not the other way around. Encore.ts is the TypeScript backend framework that gives you type-safe APIs, automatic infrastructure provisioning, and built-in observability — all without writing a single line of Terraform or Docker configuration. In this tutorial, you will build a complete task management API from scratch.
What You Will Learn
By the end of this tutorial, you will be able to:
- Set up an Encore.ts project with TypeScript
- Define type-safe API endpoints with automatic request/response validation
- Create and manage SQL databases with migrations
- Implement pub/sub messaging between services
- Schedule cron jobs for recurring tasks
- Use Encore's built-in observability (tracing, logging, metrics)
- Deploy to the cloud with automatic infrastructure provisioning
Prerequisites
Before starting, make sure you have:
- Node.js 18+ installed (
node --version) - TypeScript experience (types, generics, async/await)
- Basic REST API concepts (HTTP methods, status codes, JSON)
- A code editor — VS Code or Cursor recommended
- An Encore account (free tier available at encore.dev)
Why Encore.ts?
The TypeScript backend ecosystem has many options — Express, Fastify, Hono, NestJS — so why choose Encore? Here is what makes it different:
| Feature | Encore.ts | Fastify | NestJS | Hono |
|---|---|---|---|---|
| Type Safety | End-to-end, compile-time | Manual with schemas | Decorators-based | Manual |
| Infrastructure | Automatic provisioning | DIY | DIY | DIY |
| Database | Built-in migrations | External ORM | External ORM | External |
| Pub/Sub | Built-in primitive | External library | External module | Not included |
| Cron Jobs | Built-in primitive | External scheduler | External scheduler | Not included |
| Observability | Automatic tracing | Manual setup | Manual setup | Manual |
| Local Dev | Full cloud emulation | Manual Docker | Manual Docker | Manual |
Encore takes a fundamentally different approach: you declare what infrastructure you need in your TypeScript code, and Encore provisions it automatically — both locally for development and in the cloud for production. No Terraform, no Docker Compose files, no Kubernetes manifests.
Step 1: Install Encore CLI and Create a Project
First, install the Encore CLI:
# macOS
brew install encoredev/tap/encore
# Linux
curl -L https://encore.dev/install.sh | bash
# Windows (WSL2)
curl -L https://encore.dev/install.sh | bashVerify the installation:
encore versionNow create a new project:
encore app create task-api --example=ts/empty
cd task-apiThis creates a minimal Encore.ts project. Let us look at the structure:
task-api/
├── encore.app # Encore app configuration
├── package.json
├── tsconfig.json
└── ... (minimal starter files)
The encore.app file contains your app ID and configuration. Unlike traditional frameworks, there is no index.ts or server bootstrap file — Encore discovers your services automatically.
Step 2: Create Your First Service and API Endpoint
In Encore, a service is simply a directory with an encore.service.ts file. Let us create a task management service:
mkdir taskCreate the service definition:
// task/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("task");Now create the API endpoints:
// task/task.ts
import { api } from "encore.dev/api";
// Define the Task interface
interface Task {
id: number;
title: string;
description: string;
completed: boolean;
createdAt: string;
}
// In-memory storage (we will add a database later)
let tasks: Task[] = [];
let nextId = 1;
// Create a new task
export const create = api(
{ expose: true, method: "POST", path: "/tasks" },
async (req: { title: string; description: string }): Promise<Task> => {
const task: Task = {
id: nextId++,
title: req.title,
description: req.description,
completed: false,
createdAt: new Date().toISOString(),
};
tasks.push(task);
return task;
}
);
// List all tasks
export const list = api(
{ expose: true, method: "GET", path: "/tasks" },
async (): Promise<{ tasks: Task[] }> => {
return { tasks };
}
);
// Get a single task by ID
export const get = api(
{ expose: true, method: "GET", path: "/tasks/:id" },
async (req: { id: number }): Promise<Task> => {
const task = tasks.find((t) => t.id === req.id);
if (!task) {
throw new Error(`Task ${req.id} not found`);
}
return task;
}
);Notice something important: there is no server setup, no middleware configuration, no route registration. You just define functions and Encore handles the rest. The api() function provides:
- Automatic request validation — if a required field is missing, Encore returns a 400 error
- Type-safe path parameters —
:idis automatically parsed as a number - Automatic serialization — your return type is serialized to JSON
Run the development server:
encore runEncore starts a local development environment with a built-in dashboard at http://localhost:9400. You can test your API:
curl -X POST http://localhost:4000/tasks \
-H "Content-Type: application/json" \
-d '{"title": "Learn Encore", "description": "Build a task API"}'Step 3: Add a PostgreSQL Database
In-memory storage is fine for prototyping, but let us add a real database. With Encore, you do not need to install PostgreSQL or write Docker Compose files — just declare the database in your code:
// task/db.ts
import { SQLDatabase } from "encore.dev/storage/sqldb";
// Declare the database — Encore provisions it automatically
const db = new SQLDatabase("taskdb", {
migrations: "./migrations",
});
export default db;Create the migrations directory and first migration:
mkdir -p task/migrations-- task/migrations/001_create_tasks.up.sql
CREATE TABLE tasks (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_completed ON tasks(completed);Now update the API to use the database:
// task/task.ts
import { api } from "encore.dev/api";
import db from "./db";
interface Task {
id: number;
title: string;
description: string;
completed: boolean;
createdAt: string;
}
interface CreateParams {
title: string;
description: string;
}
interface ListResponse {
tasks: Task[];
}
// Create a new task
export const create = api(
{ expose: true, method: "POST", path: "/tasks" },
async (req: CreateParams): Promise<Task> => {
const row = await db.queryRow`
INSERT INTO tasks (title, description)
VALUES (${req.title}, ${req.description})
RETURNING id, title, description, completed, created_at
`;
return rowToTask(row!);
}
);
// List all tasks
export const list = api(
{ expose: true, method: "GET", path: "/tasks" },
async (): Promise<ListResponse> => {
const rows = await db.query`
SELECT id, title, description, completed, created_at
FROM tasks
ORDER BY created_at DESC
`;
const tasks: Task[] = [];
for await (const row of rows) {
tasks.push(rowToTask(row));
}
return { tasks };
}
);
// Get a single task
export const get = api(
{ expose: true, method: "GET", path: "/tasks/:id" },
async (req: { id: number }): Promise<Task> => {
const row = await db.queryRow`
SELECT id, title, description, completed, created_at
FROM tasks WHERE id = ${req.id}
`;
if (!row) throw new Error(`Task ${req.id} not found`);
return rowToTask(row);
}
);
// Update a task
export const update = api(
{ expose: true, method: "PUT", path: "/tasks/:id" },
async (req: {
id: number;
title?: string;
description?: string;
completed?: boolean;
}): Promise<Task> => {
const row = await db.queryRow`
UPDATE tasks SET
title = COALESCE(${req.title ?? null}, title),
description = COALESCE(${req.description ?? null}, description),
completed = COALESCE(${req.completed ?? null}, completed)
WHERE id = ${req.id}
RETURNING id, title, description, completed, created_at
`;
if (!row) throw new Error(`Task ${req.id} not found`);
return rowToTask(row);
}
);
// Delete a task
export const remove = api(
{ expose: true, method: "DELETE", path: "/tasks/:id" },
async (req: { id: number }): Promise<void> => {
await db.exec`DELETE FROM tasks WHERE id = ${req.id}`;
}
);
// Helper to convert a database row to a Task
function rowToTask(row: any): Task {
return {
id: row.id,
title: row.title,
description: row.description,
completed: row.completed,
createdAt: row.created_at.toISOString(),
};
}When you run encore run again, Encore automatically provisions a local PostgreSQL database, runs the migrations, and connects your service. No Docker, no connection strings, no environment variables.
Step 4: Add a Notification Service with Pub/Sub
Let us add a second service that sends notifications when tasks are completed. First, define a pub/sub topic:
// task/events.ts
import { Topic } from "encore.dev/pubsub";
// Define the event type
export interface TaskCompletedEvent {
taskId: number;
title: string;
completedAt: string;
}
// Create the topic
export const taskCompleted = new Topic<TaskCompletedEvent>("task-completed", {
deliveryGuarantee: "at-least-once",
});Update the update endpoint to publish an event when a task is completed:
// In task/task.ts — add this import at the top
import { taskCompleted } from "./events";
// Update the update function to publish events
export const update = api(
{ expose: true, method: "PUT", path: "/tasks/:id" },
async (req: {
id: number;
title?: string;
description?: string;
completed?: boolean;
}): Promise<Task> => {
const row = await db.queryRow`
UPDATE tasks SET
title = COALESCE(${req.title ?? null}, title),
description = COALESCE(${req.description ?? null}, description),
completed = COALESCE(${req.completed ?? null}, completed)
WHERE id = ${req.id}
RETURNING id, title, description, completed, created_at
`;
if (!row) throw new Error(`Task ${req.id} not found`);
const task = rowToTask(row);
// Publish event if task was marked as completed
if (req.completed === true) {
await taskCompleted.publish({
taskId: task.id,
title: task.title,
completedAt: new Date().toISOString(),
});
}
return task;
}
);Now create the notification service:
mkdir notification// notification/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("notification");// notification/notification.ts
import { Subscription } from "encore.dev/pubsub";
import { taskCompleted, TaskCompletedEvent } from "../task/events";
// Subscribe to task completion events
const _ = new Subscription(taskCompleted, "notify-on-complete", {
handler: async (event: TaskCompletedEvent) => {
console.log(
`Task completed: "${event.title}" (ID: ${event.taskId}) at ${event.completedAt}`
);
// In production, you would send an email, Slack message, etc.
},
});That is it. Encore handles:
- Message serialization with type safety
- Delivery guarantees (at-least-once)
- Local emulation using an in-memory message broker
- Cloud provisioning of the actual pub/sub infrastructure (e.g., GCP Pub/Sub, AWS SNS/SQS)
Step 5: Add Cron Jobs for Recurring Tasks
Let us add a cron job that sends a daily summary of incomplete tasks:
// task/cron.ts
import { CronJob } from "encore.dev/cron";
import db from "./db";
// Run every day at 9:00 AM UTC
const dailySummary = new CronJob("daily-task-summary", {
title: "Daily Task Summary",
schedule: "0 9 * * *",
endpoint: sendDailySummary,
});
async function sendDailySummary() {
const row = await db.queryRow`
SELECT
COUNT(*) FILTER (WHERE completed = false) as pending,
COUNT(*) FILTER (WHERE completed = true) as done,
COUNT(*) as total
FROM tasks
`;
console.log(
`Daily Summary — Pending: ${row!.pending}, Completed: ${row!.done}, Total: ${row!.total}`
);
}Encore cron jobs are:
- Declared in code — no external scheduler or crontab needed
- Automatically registered — Encore discovers them at compile time
- Visible in the dashboard — you can see execution history and logs
Step 6: Authentication and Authorization
Encore provides a built-in auth handler pattern. Let us add API key authentication:
// auth/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("auth");// auth/auth.ts
import { authHandler } from "encore.dev/auth";
import { Header } from "encore.dev/api";
interface AuthParams {
authorization: Header<"Authorization">;
}
interface AuthData {
userId: string;
role: string;
}
// Define the auth handler
export const auth = authHandler(
async (params: AuthParams): Promise<AuthData> => {
const token = params.authorization?.replace("Bearer ", "");
if (!token) {
throw new Error("Missing authorization token");
}
// In production, verify JWT or look up API key
// For this tutorial, we use a simple check
if (token === "admin-secret-key") {
return { userId: "admin", role: "admin" };
}
throw new Error("Invalid token");
}
);Now protect endpoints by adding auth: true:
// In task/task.ts — update the create endpoint
export const create = api(
{ expose: true, auth: true, method: "POST", path: "/tasks" },
async (req: CreateParams): Promise<Task> => {
// req now includes auth data
// ... same implementation
}
);Requests to protected endpoints now require a valid Authorization header.
Step 7: Testing Your API
Encore has built-in testing support. Create a test file:
// task/task.test.ts
import { describe, it, expect } from "vitest";
import { create, list, get, update, remove } from "./task";
describe("Task API", () => {
it("should create a task", async () => {
const task = await create({
title: "Test Task",
description: "A test task",
});
expect(task.title).toBe("Test Task");
expect(task.description).toBe("A test task");
expect(task.completed).toBe(false);
expect(task.id).toBeGreaterThan(0);
});
it("should list tasks", async () => {
const result = await list();
expect(result.tasks.length).toBeGreaterThan(0);
});
it("should update a task", async () => {
const task = await create({
title: "To Complete",
description: "Will be completed",
});
const updated = await update({
id: task.id,
completed: true,
});
expect(updated.completed).toBe(true);
});
it("should delete a task", async () => {
const task = await create({
title: "To Delete",
description: "Will be deleted",
});
await remove({ id: task.id });
await expect(get({ id: task.id })).rejects.toThrow();
});
});Run the tests:
encore test ./task/...Encore automatically provisions a separate test database for each test run, runs migrations, and tears it down afterward. No test fixtures or cleanup code needed.
Step 8: Explore the Local Development Dashboard
One of Encore's killer features is the local development dashboard. When your app is running (encore run), open http://localhost:9400 to access:
- Service Catalog — all your services, endpoints, and their types
- API Explorer — test endpoints directly from the browser
- Flow Diagrams — visualize how services communicate
- Trace Explorer — inspect request traces with timing breakdowns
- Database Explorer — browse your database tables
- Pub/Sub Monitor — see published messages and subscriptions
This dashboard is generated automatically from your code — no Swagger configuration, no OpenAPI spec files, no manual documentation.
Step 9: Generate a Client SDK
Encore can generate type-safe client SDKs for your API:
encore gen client task-api --output=./client --lang=typescriptThis generates a TypeScript client that you can use in your frontend:
import Client from "./client";
const client = new Client({ baseURL: "http://localhost:4000" });
// Fully typed — IDE autocomplete works perfectly
const task = await client.task.create({
title: "From Frontend",
description: "Created via generated client",
});
const allTasks = await client.task.list();The generated client includes:
- All request/response types matching your backend
- Automatic serialization for dates, enums, etc.
- Error handling with typed error responses
- Authentication support built-in
Step 10: Deploy to the Cloud
Deploying an Encore application is remarkably simple:
git add -A
git commit -m "feat: task management API with database, pub/sub, and cron"
git push encore mainEncore handles everything:
- Builds your application
- Provisions infrastructure — database, pub/sub topics, cron schedulers
- Configures networking — service mesh, load balancing, TLS
- Deploys with zero downtime
- Sets up monitoring — distributed tracing, logging, metrics
You can deploy to Encore Cloud (managed) or to your own AWS or GCP account. Either way, Encore provisions the right infrastructure services automatically.
Monitor your deployment from the Encore Cloud dashboard:
encore app openProject Architecture Overview
Here is what we built:
task-api/
├── encore.app
├── task/
│ ├── encore.service.ts # Service definition
│ ├── task.ts # CRUD API endpoints
│ ├── db.ts # Database declaration
│ ├── events.ts # Pub/sub topic definition
│ ├── cron.ts # Cron job definitions
│ ├── task.test.ts # Integration tests
│ └── migrations/
│ └── 001_create_tasks.up.sql
├── notification/
│ ├── encore.service.ts # Service definition
│ └── notification.ts # Event subscriber
└── auth/
├── encore.service.ts # Service definition
└── auth.ts # Auth handler
Three services, a database, pub/sub messaging, cron jobs, authentication, and cloud deployment — all in around 200 lines of TypeScript. No Terraform, no Docker, no Kubernetes, no environment variables.
Troubleshooting
Common Issues
"Database migration failed"
Make sure your SQL file ends with .up.sql and is numbered sequentially (001, 002, etc.). Check for syntax errors in the SQL.
"Service not discovered"
Ensure you have an encore.service.ts file in the service directory with a valid new Service() call.
"Auth handler not found"
The auth handler must be exported from a file in a service directory. Make sure authHandler is imported from encore.dev/auth.
"Port already in use" Encore uses port 4000 by default. If it is in use, Encore will pick the next available port. Check the terminal output for the actual URL.
Next Steps
Now that you have a working Encore.ts application, here are some ideas to extend it:
- Add a frontend using the generated client SDK with Next.js or React
- Add more services — user management, analytics, file uploads
- Implement webhooks using Encore's pub/sub for external integrations
- Set up environments — staging and production with different configurations
- Add secrets management using
encore secret set - Explore Encore's metrics for custom business metrics
Related Tutorials
- Building REST APIs with Hono and Bun — Compare with a lightweight approach
- NestJS TypeORM PostgreSQL REST API — Traditional Node.js backend
- Fastify TypeScript REST API — Fastify-based alternative
Conclusion
Encore.ts represents a paradigm shift in backend development. Instead of assembling dozens of packages, writing infrastructure-as-code, and configuring CI/CD pipelines, you focus on your business logic and let Encore handle the rest.
In this tutorial, you built a complete task management API with:
- Type-safe endpoints with automatic validation
- PostgreSQL database with migrations
- Pub/sub messaging between services
- Cron jobs for scheduled tasks
- Authentication with an auth handler
- Tests with automatic database provisioning
- Cloud deployment with zero infrastructure configuration
The key takeaway is that Encore does not just save you boilerplate — it eliminates entire categories of work (infrastructure provisioning, observability setup, client SDK generation) that traditionally consume significant engineering time. Whether you are building a startup MVP or scaling to millions of users, Encore.ts gives you a solid, type-safe foundation that grows with your needs.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Build and Deploy a Serverless API with Cloudflare Workers, Hono, and D1
Learn how to build a production-ready serverless REST API using Cloudflare Workers, the Hono web framework, and D1 SQLite database — from project setup to global deployment.

Building Production-Ready APIs with Fastify and TypeScript
Learn how to build fast, type-safe REST APIs with Fastify and TypeScript. This comprehensive guide covers project setup, schema validation, authentication, database integration, error handling, testing, and deployment best practices.

Build a Type-Safe GraphQL API with Next.js App Router, Yoga, and Pothos
Learn how to build a fully type-safe GraphQL API using Next.js 15 App Router, GraphQL Yoga, and Pothos schema builder. This hands-on tutorial covers schema design, queries, mutations, authentication middleware, and a React client with urql.