Skip to content

Introduction

Create an Outer server, define a schema, enable auth, add a procedure, and start with automatic migrations.

Updated View as Markdown

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 dev

The 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.json

Keep 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/sdk and BuiltOuter.client()
  • new Outer() — constructor options and what .start() returns
  • schema() — 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
Navigation

Type to search…

↑↓ navigate↵ selectEsc close