Quickstart Guide

Build an app in 5 minutes with Encore

Let's build something small with a surprising amount of real infrastructure.

You'll create a PostgreSQL-backed URL shortener, with the database provisioned and every request traced automatically. Then grow it into a multi-service app by adding a second service, its own database, and a Pub/Sub topic in code, run it all locally, and deploy it to the cloud.

Before you start

You'll need:

  • Node.js Go
  • Docker. Make sure it's running before step 2 so Encore can provision PostgreSQL for you locally.

1. Install Encore and create your app

Install the Encore CLI and create an app from the URL shortener starter:

Install Encore
brew install encoredev/tap/encore &&
encore app create --example=ts/url-shortener
Copy
Install Encore
brew install encoredev/tap/encore &&
encore app create --example=url-shortener
Copy

If you're new to Encore, the CLI asks you to create a free account, which you'll need to deploy the app and manage secrets using Encore's cloud platform.

It then offers to install Encore's AI instructions, letting you complete the rest of this guide using simple prompts in your AI coding tool. Finally, choose a name for your app. The CLI creates a directory with that name.

2. Run your app

Make sure Docker is running, then start your app:

$ cd your-app-name # replace with the app name you picked
$ encore run
Building Encore application graph...

Encore automatically provisions the PostgreSQL database and runs its migrations.

Open the local dashboard

Open http://localhost:9400 in your browser.

The local dashboard includes an API explorer, generated documentation, a database explorer, and distributed tracing. Encore Flow gives you a live map of your service architecture.

3. Call your API

In the API Explorer, call url.shorten. Encore generates the request form from your code, so it's ready to use:

Each call appears in the Traces column on the right.

Call it a few more times with different URLs, each one adding another row to the database. Then call url.get with one of the returned ids to fetch a URL back.

4. Look at a trace

In the local dashboard, select a POST /url request from the right-hand column.

Encore captures a distributed trace for every request, with no logging or instrumentation code required. Each trace shows:

  • Summary: duration, API calls, database queries, published messages, and logs.
  • Payloads: the full request and response.
  • Timeline: where the request spent its time.
  • Logs: every log line correlated with that request.

Compare it with a GET /url/:id trace. The database operation is now a SELECT instead of an INSERT.

See a failure in a trace

Traces are most useful when something breaks. Call url.get with an id that doesn't exist, such as does-not-exist.

Open the failed request's trace. It shows the SELECT that returned no rows and the error returned by the endpoint.

The endpoint throws APIError.notFound. Encore returns a 404 and records the error in the trace.

The database returns sql.ErrNoRows, which the endpoint passes back. Encore maps it to a 404, returns the message sql: no rows in result set, and records the error in the trace.

Encore captures the same trace data in every environment. In a multi-service app, traces follow requests across services, Pub/Sub messages, and cron jobs. Learn more in the tracing documentation.

Inspect the database

Now look at the data written by the query.

Open DB Explorer in the local dashboard and select the url table. You'll see one row for each shortened URL, and from here you can browse and edit rows, add records, and run ad hoc SQL in the SQL console.

How Encore provisions your database

You didn't create the database or run its migrations yourself. The SQLDatabase declaration tells Encore to do both, locally and in every cloud environment:

  • Locally, Encore runs PostgreSQL in Docker.
  • In the cloud, deploying to your own account provisions Amazon RDS on AWS or Cloud SQL on GCP, with Neon supported on both.

There's no Terraform to write and no connection strings or credentials to manage, and the Cloud Dashboard gives you this same database explorer for your cloud environments.

5. Take a look at the code

Open url/url.ts. This is the endpoint you just traced:

url/url.ts
import { api } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { randomBytes } from "node:crypto"; // 'url' database is used to store the URLs that are being shortened. const db = new SQLDatabase("url", { migrations: "./migrations" }); // shorten shortens a URL. export const shorten = api( { expose: true, auth: false, method: "POST", path: "/url" }, async ({ url }: ShortenParams): Promise<URL> => { const id = randomBytes(6).toString("base64url"); await db.exec` INSERT INTO url (id, original_url) VALUES (${id}, ${url}) `; return { id, url }; } );

This is standard TypeScript. Two declarations connect it to Encore:

  • api defines a public endpoint. Encore handles routing, request validation, error handling, observability, and API documentation.
  • new SQLDatabase(...) declares the database. Encore uses the declaration to provision PostgreSQL and include its queries in traces.

The shorten endpoint belongs to the url service, defined by encore.service.ts in the same folder:

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

Everything under url/ belongs to the url service. To add another service, create a directory with an encore.service.ts that exports a new Service.

Learn more about services, APIs, and databases.

Open url/url.go. This is the endpoint you just traced:

url/url.go
package url import ( "context" "encore.dev/storage/sqldb" ) // Shorten shortens a URL. // //encore:api public method=POST path=/url func Shorten(ctx context.Context, p *ShortenParams) (*URL, error) { id, err := generateID() if err != nil { return nil, err } else if err := insert(ctx, id, p.URL); err != nil { return nil, err } return &URL{ID: id, URL: p.URL}, nil } // 'url' database is used to store the URLs that are being shortened. var db = sqldb.NewDatabase("url", sqldb.DatabaseConfig{ Migrations: "./migrations", })

This is standard Go. Two declarations connect it to Encore:

  • //encore:api public defines Shorten as a public endpoint in the url service. Encore handles routing, request validation, error handling, observability, and API documentation.
  • sqldb.NewDatabase(...) declares the database. Encore uses the declaration to provision PostgreSQL and include its queries in traces.

To add another service, create a Go package and annotate its endpoints the same way. Learn more about defining APIs and databases.

6. Add some infrastructure

The Encore.ts SDK lets you declare infrastructure directly in code, including Pub/Sub topics, object storage, and cron jobs.

The Encore.go SDK lets you declare infrastructure directly in code, including Pub/Sub topics, object storage, and cron jobs.

The next three steps build on each other: add a second service, give it its own database, then connect the two with Pub/Sub.

Each step takes only a few lines of code. There's nothing else to install, configure, or wire up. Save your changes and Encore provisions the infrastructure and reloads your app.

Add a service

Splitting a backend into services usually means wiring up service discovery, clients, and inter-service authentication. With Encore, you create a directory and call the other service like a function. Tracing follows the request across both services automatically.

You'll add a stats service that reports how many URLs have been shortened, by calling the url service to count them.

A directory containing an encore.service.ts defines a service, so add stats/ next to the existing url/ directory:

/your-app-name ├── url // existing url service │ ├── encore.service.ts │ ├── migrations │ └── url.ts └── stats // new stats service ├── encore.service.ts └── stats.ts

Create stats/encore.service.ts:

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

Next, create stats/stats.ts. Importing from ~encore/clients lets you call the url service like a local function:

stats/stats.ts
import { api } from "encore.dev/api"; import { url } from "~encore/clients"; export const count = api( { expose: true, method: "GET", path: "/stats" }, async (): Promise<{ total: number }> => { const { urls } = await url.list(); return { total: urls.length }; } );

A Go package with an API endpoint defines a service, so add stats/ next to the existing url/ directory:

/your-app-name ├── url // existing url service │ ├── migrations │ ├── index.go │ └── url.go └── stats // new stats service └── stats.go

The url service doesn't have an endpoint that returns a total, so add one to url/url.go. Marking it private makes it callable from other services, but not from outside the application:

url/url.go
var db = sqldb.NewDatabase("url", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type CountResponse struct { Total int } // The path can't be /url/count, which would conflict with /url/:id. // //encore:api private method=GET path=/url-count func Count(ctx context.Context) (*CountResponse, error) { var total int err := db.QueryRow(ctx, `SELECT count(*) FROM url`).Scan(&total) return &CountResponse{Total: total}, err }

Create stats/stats.go. Importing encore.app/url lets you call the url service like a local function:

stats/stats.go
package stats import ( "context" "encore.app/url" ) type Response struct { Total int } //encore:api public method=GET path=/stats func Count(ctx context.Context) (*Response, error) { resp, err := url.Count(ctx) if err != nil { return nil, err } return &Response{Total: resp.Total}, nil }

In the API Explorer, call stats.count, then open its trace. You'll see a single request spanning two services, including the call to url.list and its database query.

In the API Explorer, call stats.Count, then open its trace. You'll see a single request spanning two services, including the call to url.Count and its database query.

Open Flow in the local dashboard to see the new dependency between stats and url.

Add a database

Now give stats its own database. Databases are declared in code the same way services are, with no cluster to provision or connection string to manage, and queries to every database appear in your traces.

You'll give stats a table to track how many times each URL has been shortened. For now you'll write to it from an endpoint you call by hand; in the next step, Pub/Sub will fill it in automatically.

Create the migration that defines its schema:

stats/migrations/1_create_clicks.up.sql
CREATE TABLE clicks ( id TEXT PRIMARY KEY, clicks BIGINT NOT NULL DEFAULT 0 );

Declare the database and an endpoint to record clicks in stats/stats.ts:

stats/stats.ts
import { api } from "encore.dev/api"; import { url } from "~encore/clients"; import { SQLDatabase } from "encore.dev/storage/sqldb"; export const db = new SQLDatabase("stats", { migrations: "./migrations" }); export const record = api( { expose: true, method: "POST", path: "/stats/:id" }, async ({ id }: { id: string }): Promise<void> => { await db.exec` INSERT INTO clicks (id, clicks) VALUES (${id}, 1) ON CONFLICT (id) DO UPDATE SET clicks = clicks.clicks + 1 `; } ); export const count = api( { expose: true, method: "GET", path: "/stats" }, async (): Promise<{ total: number }> => { const { urls } = await url.list(); return { total: urls.length }; } );

Create the migration that defines its schema:

stats/migrations/1_create_clicks.up.sql
CREATE TABLE clicks ( id TEXT PRIMARY KEY, clicks BIGINT NOT NULL DEFAULT 0 );

Declare the database and an endpoint to record clicks in stats/stats.go:

stats/stats.go
package stats import ( "context" "encore.app/url" "encore.dev/storage/sqldb" ) var db = sqldb.NewDatabase("stats", sqldb.DatabaseConfig{ Migrations: "./migrations", }) //encore:api public method=POST path=/stats/:id func Record(ctx context.Context, id string) error { _, err := db.Exec(ctx, ` INSERT INTO clicks (id, clicks) VALUES ($1, 1) ON CONFLICT (id) DO UPDATE SET clicks = clicks.clicks + 1 `, id) return err } type Response struct { Total int } //encore:api public method=GET path=/stats func Count(ctx context.Context) (*Response, error) { resp, err := url.Count(ctx) if err != nil { return nil, err } return &Response{Total: resp.Total}, nil }

Encore provisions new databases at startup, not on hot reload, so restart the app:

$ encore run

In the API Explorer, call stats.record with any id, a few times over. Open DB Explorer, switch to the stats database, and select the clicks table to see the count increase.

In the API Explorer, call stats.Record with any id, a few times over. Open DB Explorer, switch to the stats database, and select the clicks table to see the count increase.

Add Pub/Sub

Move work out of the request path so slower tasks don't block the response. Declare a Pub/Sub topic in code and Encore runs it locally. When you deploy, Encore provisions GCP Pub/Sub or Amazon SNS/SQS, with no broker to configure or credentials to connect.

You'll publish an event whenever a URL is shortened, and have stats subscribe to it. The url service no longer needs to know that stats exists, and the counting happens outside the request.

First, add the Pub/Sub import to url/url.ts:

url/url.ts
import { Topic } from "encore.dev/pubsub";

Then declare the topic alongside the existing database:

url/url.ts
const db = new SQLDatabase("url", { migrations: "./migrations" }); export interface ShortenedEvent { id: string; url: string; } export const shortened = new Topic<ShortenedEvent>("shortened", { deliveryGuarantee: "at-least-once", });

Then publish an event from shorten, immediately after the INSERT:

url/url.ts
export const shorten = api( { expose: true, auth: false, method: "POST", path: "/url" }, async ({ url }: ShortenParams): Promise<URL> => { const id = randomBytes(6).toString("base64url"); await db.exec` INSERT INTO url (id, original_url) VALUES (${id}, ${url}) `; await shortened.publish({ id, url }); return { id, url }; } );

Finally, create stats/subscriptions.ts to subscribe from the stats service:

stats/subscriptions.ts
import { Subscription } from "encore.dev/pubsub"; import { shortened } from "../url/url"; import { db } from "./stats"; const _ = new Subscription(shortened, "count-shortened", { handler: async (event) => { await db.exec` INSERT INTO clicks (id, clicks) VALUES (${event.id}, 1) ON CONFLICT (id) DO UPDATE SET clicks = clicks.clicks + 1 `; }, });

First, add the Pub/Sub import to url/url.go:

url/url.go
import "encore.dev/pubsub"

Then declare the topic alongside the existing database:

url/url.go
var db = sqldb.NewDatabase("url", sqldb.DatabaseConfig{ Migrations: "./migrations", }) type ShortenedEvent struct { ID string URL string } var Shortened = pubsub.NewTopic[*ShortenedEvent]("shortened", pubsub.TopicConfig{ DeliveryGuarantee: pubsub.AtLeastOnce, })

Then publish an event from Shorten, immediately after the insert:

url/url.go
//encore:api public method=POST path=/url func Shorten(ctx context.Context, p *ShortenParams) (*URL, error) { id, err := generateID() if err != nil { return nil, err } else if err := insert(ctx, id, p.URL); err != nil { return nil, err } if _, err := Shortened.Publish(ctx, &ShortenedEvent{ID: id, URL: p.URL}); err != nil { return nil, err } return &URL{ID: id, URL: p.URL}, nil }

Finally, create stats/subscriptions.go to subscribe from the stats service:

stats/subscriptions.go
package stats import ( "context" "encore.app/url" "encore.dev/pubsub" ) var _ = pubsub.NewSubscription( url.Shortened, "count-shortened", pubsub.SubscriptionConfig[*url.ShortenedEvent]{ Handler: func(ctx context.Context, event *url.ShortenedEvent) error { _, err := db.Exec(ctx, ` INSERT INTO clicks (id, clicks) VALUES ($1, 1) ON CONFLICT (id) DO UPDATE SET clicks = clicks.clicks + 1 `, event.ID) return err }, }, )

In the API Explorer, call url.shorten a few times, then open one of the traces. The publish appears in the request trace, and the subscription runs in a separate trace linked to it.

Check the clicks table in the DB Explorer again. The rows are now written by the subscriber, without url.shorten waiting for it.

Open Flow in the local dashboard to see the topic and subscription alongside your services.

7. Deploy your app

Encore's cloud platform includes free development hosting. For production, it can deploy to your own AWS or GCP account.

If you skipped account creation in step 1, first link your local app to Encore.

Push your changes to deploy:

$ git add -A .
$ git commit -m 'Initial commit'
$ git push encore

Encore's cloud platform builds and tests your app, provisions the PostgreSQL database, and deploys everything to a staging environment. The command returns a deployment URL like https://app.encore.dev/$APP_ID/deploys/....

Open the URL to follow the deployment in the Cloud Dashboard.

Once the deployment finishes, call the API using its cloud URL and inspect the trace in the Cloud Dashboard. The tracing works just as it did locally, but the app now runs against cloud infrastructure.

From the Cloud Dashboard you can also connect your cloud account and integrate with GitHub.

What's next?

  • Explore the Encore.ts primitivesEncore.go primitives to declare Pub/Sub, object storage, cron jobs, secrets, and more.
  • Build with your AI coding tool. With Encore's AI instructions installed, your agent knows how to declare infrastructure in code, so a prompt like "Add a cron job that deletes URLs older than 30 days and logs how many it removed" is enough. See AI tools integration.
  • Follow the Uptime Monitor tutorial to build a multi-service, event-driven system.
  • Join the Encore community on Discord to ask questions and meet other developers.