Outer is a TypeScript backend framework built on Kysely, oRPC, and Better Auth. You describe your database, auth, and API in one builder chain, and get back a fetch-compatible handler you can mount anywhere.
By default it runs on PGlite — real Postgres, embedded, writing to local disk — so you need no external services to start.
Before you begin
You need Node.js 20 or later and a package manager. The template installs everything else.
Create a server
Scaffold a project and start it:
npx giget@latest gh:ilhajs/outer/templates/minimal my-outer-app
cd my-outer-app
npm install
npm run devThe server listens on http://localhost:3000. Call POST /rpc/user/me to check that it responds.
Pick a different template
| Template | What you get | Command |
|---|---|---|
minimal |
The smallest Outer server — new Outer(...).start() behind srvx, no frontend. |
npx giget@latest gh:ilhajs/outer/templates/minimal |
ilha |
Full-stack starter: Outer as a Nitro server entry, Ilha + Vite on the frontend. | npx giget@latest gh:ilhajs/outer/templates/ilha |
cloudflare |
Outer on Cloudflare Workers — a Durable Object’s SQLite for data, R2 for uploads, instead of PGlite. | npx giget@latest gh:ilhajs/outer/templates/cloudflare |
vercel-neon |
Outer on Vercel Functions — Neon Postgres for data, Vercel Blob for uploads, instead of PGlite. | npx giget@latest gh:ilhajs/outer/templates/vercel-neon |
Write your first server
Every Outer app is one chain. Read it top to bottom: a schema version, auth, then the procedures your clients call. .start() builds the server and applies migrations in one step.
import { Outer } from "@outerjs/server";
import { schema } from "@outerjs/server/schema";
import { pglite } from "@outerjs/server/pglite";
const v1_0 = schema("1.0.0")
.auth() // Better Auth tables: user, session, account, verification
.table("post", (t) => ({
id: t.serial().primaryKey(),
title: t.text(),
userId: t.text().references("user", "id"),
}))
.relation("user", (rel) => rel.hasMany("post", { from: "id", to: "userId" }))
.relation("post", (rel) => rel.belongsTo("user", { from: "userId", to: "id" }))
.build();
const server = await new Outer({ name: "My API", baseUrl: "http://localhost:3000", db: pglite() })
.schema(v1_0)
.auth({ secret: process.env.AUTH_SECRET! })
.procedure("user.me", (base) => base.handler(({ context }) => context.user), {
permission: "authenticated",
})
.start();
serve({ fetch: (req) => server.handle(req) });This server exposes POST /rpc/user/me plus the Better Auth routes under /api/auth/**. .auth() resolves the session once per request, so context.user is already there — you never write a getSession middleware.
Order the chain correctly
Call the methods in this order: .schema() → .middleware() → .procedure() → .start() (or .build()). Each call types the next one, so a procedure only sees the tables and context fields declared above it.
.auth(), .openapi(), .mcp(), .admin(), .files(), and .use() can appear anywhere before .start() / .build().
Run migrations
.start() calls build() then migrateToLatest() and throws if migration fails. Prefer it for self-hosted apps:
const server = await new Outer({ db: pglite() }).schema(v1_0).start();Use .build() when you need manual control — for example a deploy-time script, or logging the migration result:
const server = new Outer({ db: pglite() }).schema(v1_0).build();
const { error, results } = await server.migrator.migrateToLatest();
if (error) throw error;| Call | What it does | Prefer when |
|---|---|---|
.start() |
build() + migrateToLatest(), throws on migration failure |
Self-hosted boot |
.build() |
Finalizes the chain only — you run migrations yourself | Deploy scripts, logging results, serverless |
On serverless, run migrations from a deploy script instead of the request path — see Deployment.
Call it from a client
After the server is up, use @outerjs/sdk in the browser, or server.client(headers) during SSR — both share InferRouter types.
Project structure
my-outer-app/
├── src/
│ ├── schema.ts # schema("1.0.0")...build() versions
│ └── index.ts # await new Outer(...).schema(...).auth(...).procedure(...).start()
└── package.jsonKeep schema versions in their own file. You add versions over time and never edit old ones — or derive the next version with .extend().
Next steps
- Client — typed
@outerjs/sdkandBuiltOuter.client() new Outer()— constructor options and what.start()returnsschema()— define tables, columns, and relations- Migrations — register versions and apply them
.resource()— generate CRUD for a table.auth()— Better Auth, sessions, and API keys- Permissions — guard procedures and resources
.procedure()— write the endpoints CRUD does not cover.use()— package procedures, routes, and middleware- Database — query with Kysely and the Sola ORM
new Outer()— CORS, rate limits, health,onError- Deployment — pick a database and a host