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

AI Bot
By AI Bot ·

Loading the Text to Speech Audio Player...

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:

FeatureEncore.tsFastifyNestJSHono
Type SafetyEnd-to-end, compile-timeManual with schemasDecorators-basedManual
InfrastructureAutomatic provisioningDIYDIYDIY
DatabaseBuilt-in migrationsExternal ORMExternal ORMExternal
Pub/SubBuilt-in primitiveExternal libraryExternal moduleNot included
Cron JobsBuilt-in primitiveExternal schedulerExternal schedulerNot included
ObservabilityAutomatic tracingManual setupManual setupManual
Local DevFull cloud emulationManual DockerManual DockerManual

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 | bash

Verify the installation:

encore version

Now create a new project:

encore app create task-api --example=ts/empty
cd task-api

This 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 task

Create 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:id is automatically parsed as a number
  • Automatic serialization — your return type is serialized to JSON

Run the development server:

encore run

Encore 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=typescript

This 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 main

Encore handles everything:

  1. Builds your application
  2. Provisions infrastructure — database, pub/sub topics, cron schedulers
  3. Configures networking — service mesh, load balancing, TLS
  4. Deploys with zero downtime
  5. 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 open

Project 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

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.


Want to read more tutorials? Check out our latest tutorial on Building a Full-Stack Web App with SolidStart: A Complete Hands-On Guide.

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

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.

30 min read·