Express.js has been the go-to Node.js framework for over a decade. It's minimal, flexible, and battle-tested. But the backend landscape has evolved, and newer frameworks like Encore.ts take a different approach, trading some flexibility for built-in type safety and infrastructure automation.
So which one should you use? Let's break it down with real code examples and honest tradeoffs.
| Aspect | Express.js | Encore.ts |
|---|---|---|
| Philosophy | Minimal, unopinionated | Batteries included, infrastructure-aware |
| Type Safety | Optional (add TypeScript yourself) | Built-in, compile-time validation |
| Local Infrastructure | Configure Docker yourself | Automatic (databases, Pub/Sub, etc.) |
| Learning Curve | Low | Low-Medium |
| Ecosystem | Massive | Growing |
| Observability | Manual setup | Built-in tracing, metrics, logs |
| Best For | Maximum flexibility, existing infra | Distributed systems, type-safe APIs |
Let's start with something simple: a REST endpoint that returns a greeting.
import express from 'express';
const app = express();
app.use(express.json());
app.get('/hello/:name', (req, res) => {
res.json({ message: `Hello, ${req.params.name}!` });
});
app.listen(3000, () => {
console.log('Server running on port 3000');
});
Express gives you the building blocks. You wire up middleware, define routes, and start the server yourself.
import { api } from "encore.dev/api";
interface HelloResponse {
message: string;
}
export const hello = api(
{ method: "GET", path: "/hello/:name", expose: true },
async ({ name }: { name: string }): Promise<HelloResponse> => {
return { message: `Hello, ${name}!` };
}
);
Encore uses a declarative approach. You define the endpoint with its types, and Encore handles the server, routing, and request validation.
Verdict: Express is more explicit; Encore is more concise. If you want full control over server setup, Express wins. If you want less boilerplate and automatic type validation, Encore wins.
This is where the approaches diverge significantly.
Express doesn't validate requests by default. You typically add a validation library:
import express from 'express';
import { z } from 'zod';
const app = express();
app.use(express.json());
const CreateUserSchema = z.object({
email: z.string().email(),
name: z.string().min(1),
});
app.post('/users', (req, res) => {
const result = CreateUserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: result.error });
}
const { email, name } = result.data;
// Create user...
res.json({ id: 1, email, name });
});
You need to:
import { api } from "encore.dev/api";
interface CreateUserRequest {
email: string;
name: string;
}
interface User {
id: number;
email: string;
name: string;
}
export const createUser = api(
{ method: "POST", path: "/users", expose: true },
async (req: CreateUserRequest): Promise<User> => {
// req is already validated and typed
return { id: 1, email: req.email, name: req.name };
}
);
Encore validates requests automatically based on your TypeScript types. Invalid requests get rejected before your handler runs. No extra libraries, no manual validation. You can also use built-in validation rules for more complex constraints like min/max values and string formats.
Verdict: If type safety and automatic validation matter to you, Encore has a clear advantage. Express gives you flexibility to choose your validation approach.
Express doesn't include database support. You pick an ORM or query builder:
import express from 'express';
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
const app = express();
app.get('/users/:id', async (req, res) => {
try {
const result = await pool.query(
'SELECT id, email, name FROM users WHERE id = $1',
[req.params.id]
);
if (result.rows.length === 0) {
return res.status(404).json({ error: 'User not found' });
}
res.json(result.rows[0]);
} catch (error) {
res.status(500).json({ error: 'Database error' });
}
});
You also need to:
import { api } from "encore.dev/api";
import { SQLDatabase } from "encore.dev/storage/sqldb";
const db = new SQLDatabase("users", {
migrations: "./migrations",
});
interface User {
id: number;
email: string;
name: string;
}
export const getUser = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }): Promise<User> => {
const user = await db.queryRow<User>`
SELECT id, email, name FROM users WHERE id = ${id}
`;
if (!user) {
throw new Error("User not found");
}
return user;
}
);
Encore automatically provisions PostgreSQL databases locally during development (no Docker configuration required) and runs migrations on startup. Connection pooling is handled for you.
You can also use any database or ORM you prefer. Encore's database primitives provide automation for PostgreSQL, but you're free to connect to MySQL, MongoDB, or any other database using standard drivers. See the database documentation for details.
Verdict: Both frameworks work with any database. Encore provides additional automation for PostgreSQL that reduces setup time, especially in local development.
Express's middleware system is one of its greatest strengths:
import express from 'express';
import jwt from 'jsonwebtoken';
const app = express();
// Middleware for authentication
const authenticate = (req, res, next) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch (error) {
res.status(401).json({ error: 'Invalid token' });
}
};
// Apply to specific routes
app.get('/profile', authenticate, (req, res) => {
res.json({ userId: req.user.id });
});
// Or apply globally
app.use('/api', authenticate);
The middleware pattern is flexible and composable. You can use any authentication strategy.
Encore supports general-purpose middleware for cross-cutting concerns, plus a specialized auth handler for authentication:
import { api, Gateway, middleware } from "encore.dev/api";
import { authHandler } from "encore.dev/auth";
import { Header } from "encore.dev/api";
// General middleware for logging, rate limiting, etc.
const loggingMiddleware = middleware({}, async (req, next) => {
const start = Date.now();
const resp = await next(req);
console.log(`Request took ${Date.now() - start}ms`);
return resp;
});
// Auth handler for authentication
interface AuthParams {
authorization: Header<"Authorization">;
}
interface AuthData {
userId: string;
}
const auth = authHandler<AuthParams, AuthData>(async (params) => {
const token = params.authorization.replace("Bearer ", "");
const userId = await validateToken(token);
return { userId };
});
export const gateway = new Gateway({ authHandler: auth });
// Protected endpoint - just add auth: true
export const getProfile = api(
{ method: "GET", path: "/profile", auth: true, expose: true },
async (): Promise<{ userId: string }> => {
const { userId } = getAuthData()!;
return { userId };
}
);
Encore's middleware works similarly to Express middleware for general use cases. The auth handler is a specialized middleware that centralizes authentication at the gateway level. Endpoints opt-in with auth: true, and the auth data is type-safe and available throughout your request.
Verdict: Both frameworks support flexible middleware patterns. Express middleware is more familiar; Encore provides additional structure for authentication with type-safe auth data propagation.
This is where Encore really shines compared to Express.
Getting production-ready observability requires significant setup:
import express from 'express';
import { trace } from '@opentelemetry/api';
import promClient from 'prom-client';
const app = express();
const tracer = trace.getTracer('my-service');
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
console.log(`${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
});
next();
});
// Manual tracing
app.get('/users/:id', async (req, res) => {
const span = tracer.startSpan('getUser');
try {
// Your logic here
span.end();
res.json({ /* ... */ });
} catch (error) {
span.recordException(error);
span.end();
res.status(500).json({ error: 'Internal error' });
}
});
You need to configure:
import { api } from "encore.dev/api";
import log from "encore.dev/log";
export const getUser = api(
{ method: "GET", path: "/users/:id", expose: true },
async ({ id }: { id: number }) => {
log.info("fetching user", { userId: id });
// Every database query, API call, and service call
// is automatically traced
return { /* ... */ };
}
);
That's it. Every request is traced end-to-end. Database queries show up in traces. Service-to-service calls are correlated. You get a local development dashboard with tracing out of the box. For production deployments with Encore Cloud, you also get metrics, alerting, and integrations with Grafana and Datadog.

Verdict: If observability is important (and it should be), Encore saves you days or weeks of setup. Express gives you full control but requires significant investment.
Building distributed systems is where the approaches differ most significantly.
Building microservices with Express means setting up service discovery, inter-service communication, and distributed tracing yourself:
// users-service/index.ts
import express from 'express';
const app = express();
app.get('/users/:id', async (req, res) => {
// ...
});
app.listen(3001);
// orders-service/index.ts
import express from 'express';
import axios from 'axios';
const app = express();
app.post('/orders', async (req, res) => {
// Call users service - you manage the URL
const user = await axios.get(
`${process.env.USERS_SERVICE_URL}/users/${req.body.userId}`
);
// ...
});
app.listen(3002);
You handle service URLs, network errors, retries, and distributed tracing correlation.
// users/encore.service.ts
import { Service } from "encore.dev/service";
export default new Service("users");
// users/api.ts
export const get = api(/* ... */);
// orders/api.ts
import { users } from "~encore/clients";
export const create = api(
{ method: "POST", path: "/orders", expose: true },
async (req: CreateOrderRequest) => {
// Type-safe call, automatic tracing, no URL management
const user = await users.get({ id: req.userId });
// ...
}
);
Service calls look like function calls. Encore handles service discovery, generates type-safe clients, and correlates distributed traces automatically. You get a service catalog showing your architecture, API documentation, and how services communicate.

Verdict: If you're building microservices or distributed systems, Encore removes significant complexity with type-safe service communication and automatic service discovery. Express works but requires more infrastructure setup.
Express makes sense when:
Encore makes sense when:
If you're considering a migration, the approaches are different enough that it's not a simple port. However, your business logic can often be preserved. Encore provides a migration guide that walks through the process step by step.
The key differences to adapt:
The best way to decide is to try both with a small project:
You can also check out the Encore.ts tutorial to build a complete REST API with a database.
Have questions about choosing a framework? Join our Discord community where developers discuss architecture decisions daily.