Complete reference for @outerjs/server: the Outer constructor, every builder method and its config, the schema() builder, plugins, and the object .build() / .start() returns.
For task-oriented explanations, start with the guides — Schema, Permissions, and Procedures.
Package entry points
Import the core builder from the root and the rest from subpath entries:
| Import | Members |
|---|---|
@outerjs/server |
Outer, ORPCError, mcp, liveIterable, memoryRateLimitStore, and all context types |
@outerjs/server/pglite |
pglite |
@outerjs/server/schema |
schema, timestamps, parseSet, toSet, InferDB, SchemaResult, and the table types |
@outerjs/server/secrets |
fromEnv, fromRecord, fromSchema, memorySecrets, OuterSecrets, StandardSchemaV1 |
@outerjs/server/storage |
fromUnstorage, fromS3, memoryStorage, OuterStorage |
The schema, secrets, and storage members are only importable from their subpaths — the root exports the builder and context types.
new Outer(params)
import { Outer } from "@outerjs/server";
import { pglite } from "@outerjs/server/pglite";
const outer = new Outer({
name: "My App",
baseUrl: "https://api.example.com",
db: pglite(),
cors: { origins: ["https://app.example.com"], credentials: true },
});| Param | Type | Required | Description |
|---|---|---|---|
name |
string |
no | Display name. Used as the OpenAPI spec title and reported by _admin.meta. |
baseUrl |
string |
no | Public origin of the server. Used as Better Auth’s default baseURL and as servers[].url (<baseUrl>/rest) in the OpenAPI spec. |
db |
{ dialect: Dialect; kind: "postgres" | "sqlite"; live?: LiveProvider } |
yes | A Kysely Dialect plus its family. kind drives DDL generation, Better Auth’s schema, and error mapping, so it must match the dialect. Optional live enables live queries. Use pglite() for the zero-infra default. |
cors |
CorsConfig |
no | Cross-origin browser callers allowed to reach /rpc/**, /api/auth/**, and the admin API. Merged into Better Auth’s trustedOrigins when .auth() is used. Without it, no Access-Control-Allow-Origin header is set. |
storage |
OuterStorage |
no | Object store for file bytes. Surfaced as context.storage and used by .files(), which throws without it. See File uploads. |
secrets |
OuterSecrets |
no | Runtime-agnostic accessor for env secrets and bindings, surfaced as context.secrets. Build it with fromSchema / fromEnv / fromRecord. See Secrets. |
kv |
OuterKV |
no | Key/value store, surfaced as context.kv. Pass any unstorage instance — createStorage(...), Nitro’s useStorage(), Cloudflare KV, or Vercel Runtime Cache. Supports TTL via setItem(key, value, { ttl }). See Key/value store. |
onError |
(error, info) => void |
no | Called for unexpected failures only, never for deliberate ORPCError responses (400, 401, 403, 404, 409, …), which are application behavior. info.source is "rpc" | "rest" | "route" | "mcp". Defaults to console.error; pass () => {} to silence it. |
health |
boolean | { path?: string } |
no | Mounts GET /health with a select 1 probe — 200 {"status":"ok","database":"up"}, or 503 when the probe fails. Enabled by default; false omits it. A .route() on the same path wins. Not rate limited. |
rateLimit |
RateLimitConfig |
no | Per-caller limit on /rpc/** and /rest/**. Off by default. /api/auth/** is excluded, since Better Auth has its own. |
RateLimitConfig
| Prop | Type | Default | Description |
|---|---|---|---|
max |
number |
— | Requests allowed per window, per key. |
windowMs |
number |
— | Window length in ms. Fixed window, not sliding. |
key |
(event, user) => string |
user id, else client IP | Identifies the caller. The default falls back to x-forwarded-for / x-real-ip. |
skip |
(event) => boolean |
— | Return true to bypass the limit for a request. |
store |
RateLimitStore |
memoryRateLimitStore() |
Swap in Redis or Upstash to share counters across replicas. |
Over-limit requests get 429 with Retry-After and RateLimit-* headers.
The default store is in-process, so each replica counts separately: behind a load balancer, the effective limit is max × replicas. If your proxy strips both forwarded-IP headers, every anonymous caller shares one bucket — pass key explicitly there.
The header fallback is only trustworthy behind a proxy you control: x-forwarded-for / x-real-ip are plain request headers, so if traffic can reach the server directly a caller spoofs a fresh value per request and never hits the limit. In that setup, set key to something you can trust — a verified client IP or an API-key id.
CorsConfig
| Prop | Type | Default | Description |
|---|---|---|---|
origins |
string[] |
— | Allowed origins, matched exactly against the request’s Origin header. ["*"] echoes any origin — for public, unauthenticated APIs. |
credentials |
boolean |
false |
Sets Access-Control-Allow-Credentials: true, needed for cookie-based auth from another origin. Combining it with origins: ["*"] throws at .build() — it would let any site make authenticated requests with a visitor’s cookies. List origins explicitly. |
pglite(config?)
Embedded, zero-infra Postgres from @outerjs/server/pglite. Returns a { dialect, kind: "postgres" } pair to pass as db.
| Prop | Type | Default | Description |
|---|---|---|---|
dataDir |
string |
<cwd>/.outer/pglite |
Data directory, created if missing. Pass a memory:// URL for an in-memory instance, which suits tests. |
Bundled extensions
pglite() loads two PGlite extensions into the instance it builds. It returns { dialect, kind, live, client } — live backs Sola’s live queries, and client is the PGlite instance for anything neither the query builder nor Sola covers.
| Extension | Package | Reachable from |
|---|---|---|
vector |
@electric-sql/pglite-pgvector |
Plain SQL — CREATE EXTENSION IF NOT EXISTS vector runs before any query |
live |
@electric-sql/pglite/live |
context.db.query.<table>.live() — wired up as a LiveProvider |
Both packages are optional peers you install yourself: bun add @electric-sql/pglite @electric-sql/pglite-pgvector.
Builder methods
Call them in this order: .schema() → .middleware() → .procedure() → .start() (or .build()).
.auth(), .openapi(), .mcp(), .admin(), .files(), .route(), .resource(), and .use() can appear anywhere before .start() / .build(). .resource() needs a prior .schema() that defines its table.
.schema(schemaResult)
Registers a schema version built with schema(). Every call becomes one migration step, and the latest version types context.db. It takes a single SchemaResult argument and no options.
.auth(config)
Enables Better Auth and mounts ALL /api/auth/**. It narrows context.auth to non-null and resolves the session once per request into context.user and context.session — no getSession middleware, and no extra query however many procedures or checks read it.
AuthConfig is Better Auth’s full BetterAuthOptions minus database, which Outer wires from your db dialect:
| Prop | Type | Required | Description |
|---|---|---|---|
secret |
string |
yes | Signs and encrypts sessions, cookies, and tokens. Use at least 32 random characters in production. |
baseURL |
BetterAuthOptions["baseURL"] |
no | Defaults to the constructor’s baseUrl. Accepts Better Auth’s DynamicBaseURLConfig ({ allowedHosts, fallback?, protocol? }) for preview or dynamic domains. |
trustedOrigins |
string[] |
no | Merged with cors.origins from the constructor. CORS origins are always trusted, regardless of call order. |
| …rest | BetterAuthOptions |
no | Everything else Better Auth accepts: emailAndPassword, socialProviders, plugins, session, and so on. |
.middleware(mw)
Adds an oRPC middleware to the base context. Whatever it returns via next({ context }) is merged into context — and into the types — for every procedure defined after it.
.procedure(name, cb, options?)
Registers an RPC procedure. Dot notation nests namespaces: "user.me" is served at POST /rpc/user/me. The callback receives the oRPC base builder, so chain .input(), .output(), and .handler() on it. Names must be unique, and the _admin namespace is reserved.
options guards the procedure without a hand-written check:
| Prop | Type | Default | Description |
|---|---|---|---|
permission |
"public" | "authenticated" | "admin" | (args) => boolean |
"public" |
"authenticated" returns 401 when signed out. "admin" and function permissions return 403. |
roles |
string[] |
["admin"] |
Roles accepted by "admin". |
There is no "owner" — that needs a row, which a bare procedure has none of. Use .resource(), or check inside the handler. Any non-public permission is registered at .build(), so forgetting .auth() throws at startup instead of failing per request.
.route(method, path, handler)
Mounts a raw H3 route for webhooks and custom REST endpoints, alongside your RPC routes. handler(event, context) receives the H3 event and the same context procedures get (headers, db, auth, user, session, storage), including the already-resolved session. Raw routes are registered before /rpc/** and win on overlapping paths.
.resource(name, options?)
Generates typed CRUD procedures — list, get, create, update, delete — for a table defined in the latest .schema(). Throws at build time if the table does not exist.
ResourceOptions:
| Prop | Type | Default | Description |
|---|---|---|---|
permissions |
ResourcePermissions |
all "public" |
Per-action rules — see below. |
ownerColumn |
string |
— | Column storing the creator’s user ID. Required when any permission is "owner". When create requires a session and this is set, the current user’s ID is injected into inserts. |
listLimit |
{ default?: number; max?: number; maxSkip?: number } |
50 / 100 / 10000 |
Default and max rows per list call. maxSkip caps the offset so deep pagination cannot force full scans. |
includable |
string[] |
[] |
Relations callers may include on list and get. Included rows are not checked against the related resource’s permissions, so only allow relations that are safe to expose. |
writable |
string[] |
all columns | Allowlist of columns callers may set on create/update; every other column is server-controlled and dropped from the input. Mutually exclusive with readonly. |
readonly |
string[] |
[] |
Denylist of columns stripped from create/update input, leaving the rest writable. Mutually exclusive with writable. |
live |
boolean | { revalidateMs?: number | false; max?: number } |
off | Registers {name}.live, which is list as a stream, gated by the list permission with owner scoping included. revalidateMs (default 30000) re-checks that permission while the stream is open; max (default 100) caps subscriptions. |
ResourcePermissions — each of list, get, create, update, and delete accepts:
| Value | Meaning |
|---|---|
"public" |
No restriction (the default). |
"authenticated" |
Requires a signed-in session. |
"admin" |
Requires the admin role. user.role may be comma-separated. |
"owner" |
Requires the signed-in user to own the row (ownerColumn). Not valid for create; on list it scopes results to the caller’s rows. |
PermissionFn |
Custom check: ({ context, row? }) => boolean | Promise<boolean>. |
.openapi(config?)
Mounts GET /openapi.json and a plain-JSON REST surface under /rest/** matching the spec. Requires the optional peers @orpc/openapi and @orpc/zod.
| Prop | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true |
Pass import.meta.env.DEV or similar to gate the spec to non-production. |
.mcp(config?)
Serves the router as an MCP server over Streamable HTTP, so agents can call procedures as tools. Requires the optional peers orpc-mcp and @orpc/zod.
Exposure is opt-in per procedure: only those tagged with the mcp meta helper (mcp.tool(), mcp.resource(), mcp.prompt(), re-exported from @outerjs/server) are visible. The _admin namespace and file.* routes never are. Dots become underscores in tool names, so post.search is called as post_search.
| Prop | Type | Default | Description |
|---|---|---|---|
enabled |
boolean |
true when called |
Gate the endpoint behind an env flag without removing the call. |
path |
string |
/mcp |
Where the endpoint is mounted. |
serverInfo |
{ name?, version? } |
instance name + version | Identity reported during initialize. |
instructions |
string |
— | Guidance returned to the client during initialize. |
enableDnsRebindingProtection |
boolean |
false |
Reject Origin and Host outside the allowlists. |
allowedOrigins / allowedHosts |
string[] |
— | Exact-match allowlists used when protection is on. |
.files(config?)
Mounts the upload surface — file.upload, list, get, delete, attach, detach, plus GET /files/:id — over the tables schema().files() registers. Requires an OuterStorage on the constructor or in config. Full guide: File uploads.
| Prop | Type | Default | Description |
|---|---|---|---|
storage |
OuterStorage |
the constructor’s storage |
Overrides the instance-wide store for files only. |
maxBytes |
number |
10 * 1024 * 1024 |
Larger uploads are rejected with 413. Bytes are buffered in memory, so this is a real ceiling. |
accept |
string[] |
all types | Allowed MIME types. Wildcards such as "image/*" are accepted. |
permissions |
{ upload, list, get, delete } |
upload/list "authenticated", get/delete "owner" |
Same vocabulary as .resource(). "owner" makes the download route answer 404 — not 403 — to non-owners. |
path |
string containing :id |
"/files/:id" |
Path of the download route. |
roles |
string[] |
["admin"] |
Roles that bypass ownership. |
OuterStorage
Three methods, so core depends on no storage vendor. fromUnstorage(storage), fromS3(client, commands, bucket), and memoryStorage() ship with the package; anything else is an object literal.
type OuterStorage = {
get(key: string): Promise<Uint8Array | null>;
set(key: string, bytes: Uint8Array): Promise<void>;
delete(key: string): Promise<void>;
};OuterSecrets
Runtime-agnostic accessor for env secrets and bindings, passed as new Outer({ secrets }) and surfaced as context.secrets. fromSchema(schema, env) (validates a Zod / Standard Schema and infers the types), fromEnv() (process.env), fromRecord(record) (a typed env object), and memorySecrets(values?) ship with the package. Full guide: Secrets.
type OuterSecrets<T = Record<string, string | undefined>> = {
get<K extends keyof T & string>(key: K): T[K];
require<K extends keyof T & string>(key: K): NonNullable<T[K]>; // throws when unset/empty
readonly all: T;
};.admin(config?)
Enables the admin API — meta and schema introspection, migration status, and table CRUD — under the reserved _admin namespace at /rpc/_admin/**. Every admin procedure requires a signed-in session with an admin role, so you must also call .auth() or .build() throws.
| Prop | Type | Default | Description |
|---|---|---|---|
listLimit |
{ default?: number; max?: number } |
50 / 200 |
Default and max rows per _admin.data.list call. |
roles |
string[] |
["admin"] |
Roles granted admin access. Match Better Auth’s admin plugin adminRoles if you customize it. |
.use(plugin)
Registers an OuterPlugin. Can appear anywhere before .start() / .build(). Double-registering the same plugin.name throws. Full guide: Plugins.
.build()
Finalizes the chain and returns a BuiltOuter. Throws if any resource permission or .admin() requires auth but .auth() was never called. Does not run migrations — call migrator.migrateToLatest() yourself, or use .start() instead.
.start()
Convenience for the common build() + migrateToLatest() pair. Returns a Promise<BuiltOuter> and throws if migration fails. Prefer this on self-hosted startups; keep .build() when you need manual migration control (deploy scripts, logging the result).
const server = await new Outer({ db: pglite() }).schema(v1_0).start();BuiltOuter
| Member | Description |
|---|---|
handle(request) |
Plain Fetch handler (Request => Promise<Response>). Plug it into srvx, Bun.serve, Next.js route handlers, and others. |
client(headers?) |
In-process router client that skips HTTP entirely, for SSR. Pass the incoming request’s Headers — or a function returning them, evaluated per call — so permissions see the caller’s session. |
migrator |
Kysely migrator over the registered schema versions. Prefer .start() on boot; call await outer.migrator.migrateToLatest() when you need the result object or a separate deploy step. |
router |
The internal oRPC router. Use InferRouter<typeof outer> for type-safe client generation. |
db |
The same typed context.db handed to procedures (Kysely plus query and transact), for out-of-band work such as seeding after migrations. |
close() |
Releases the database pool — including the embedded PGlite — and any rate-limit timers. Call it from your SIGTERM/SIGINT handler, and in tests that build more than one instance. Idempotent. |
OuterPlugin
Extension point for third-party and app-specific features. Pass an instance to .use(). Full guide: Plugins.
type OuterPlugin = {
name: string;
configure?(): void;
validate?(ctx: PluginContext): void;
build?(ctx: PluginContext): PluginResult | void;
};
type PluginResult = {
procedures?: Record<string, AnyProcedure>;
routes?: Array<{ method: string; path: string; handler: (event, context) => unknown }>;
middleware?: Array<(event, next) => Promise<unknown>>;
};| Hook | When | Purpose |
|---|---|---|
configure |
At .use() time |
Fail fast on bad config. |
validate |
During build, after Outer’s built-in checks | Reject the build (for example if .auth() is missing). |
build |
After built-in procedures (admin, files) are assembled | Return procedures, routes, and/or middleware to mount. |
Plugin middleware runs after CORS and rate-limit middleware, before routes. PluginContext exposes resources (read-only), base (oRPC builder), schemas, and name.
schema() builder
import { schema, timestamps } from "@outerjs/server/schema";
export const v1_0_0 = schema("1.0.0")
.auth()
.table("post", (t) => ({
id: t.serial().primaryKey(),
title: t.text(),
body: t.text().nullable(),
authorId: t.text().references("user", "id"),
meta: t.jsonb().nullable(),
...timestamps(t),
}))
.relation("user", (rel) => rel.hasMany("post", { from: "id", to: "authorId" }))
.build();schema(version)
Starts a schema version. version is a free-form string, such as "1.0.0", shown in the OpenAPI spec and the admin API.
.auth()
Registers the Better Auth core tables (user, session, account, verification) with the admin plugin’s fields (user.role, banned, banReason, banExpires, and session.impersonatedBy) plus their relations.
Extend an auth table by re-declaring extra columns with .table("user", ...) after this call — columns merge, and yours win on name collisions. Never remove or rename the core auth columns.
| Option | Type | Default | Description |
|---|---|---|---|
roles |
string[] |
— | Declares the recognized role set, narrowing user.role. A user may hold several at once, since Better Auth stores them comma-separated. Open when omitted. |
apiKeys |
boolean |
false |
Adds the apikey table required by @better-auth/api-key — see API keys. |
session.userId and account.userId cascade on delete, so removing a user is never blocked by their sessions.
.files(options?)
Registers a file metadata table for blobs held in an object store — the counterpart to .auth() for uploads. Columns: id (primary key), key (unique, the storage key the bytes live under, so a blob can never be double-registered), name, type (MIME), size, userId (nullable, references user), plus timestamps(t). Exported as FileTables.
| Prop | Type | Default | Description |
|---|---|---|---|
owner |
boolean |
true |
Adds file.userId plus the user hasMany file and file belongsTo user relations, so it requires .auth(). Pass false for files with no per-user owner; the column and relations are omitted. |
attachTo |
string[] |
[] |
Tables files can attach to. Each name x gets a pivot x_file (fileId, entityId, nullable role, integer position) and a manyToMany relation both ways. Unknown names are a type error. |
The schema stops there: it does not read, write, or serve the bytes. That is .files() on the builder chain.
.table(name, define)
Defines a table, or extends one — re-declaring merges columns, and the later definition wins. define receives the column builder t:
| Column type | TS type (select) | Notes |
|---|---|---|
t.serial() |
number |
Database-generated; omitted from create inputs. |
t.text() |
string |
|
t.varchar() |
string |
|
t.integer() |
number |
|
t.boolean() |
boolean |
|
t.timestamp() |
Date |
Resource inputs take ISO strings; outputs are Date | string depending on the dialect. |
t.bigint() |
string |
64-bit. A string, since a JS number loses precision past 2^53. |
t.decimal() |
string |
Exact numeric (numeric, or text on SQLite). A string, so cents never pass through a float. |
t.real() |
number |
Approximate. Do not store money in it. |
t.date() |
Date |
Calendar date, no time component. |
t.jsonb() |
unknown |
|
t.uuid() |
string |
|
t.bytes() |
Uint8Array |
Raw bytes (bytea or BLOB). |
Column modifiers chain, and each returns the column:
| Modifier | Effect |
|---|---|
.nullable() |
Allows NULL. Optional in inserts and typed | null. |
.primaryKey() |
Marks the primary key, which drives the where type of resource get, update, and delete. |
.unique() |
Adds a unique constraint. |
.index() |
Non-unique index named {table}_{column}_idx. A no-op on .unique() columns. |
.default(value) |
Literal default, quoted for you by column type: .default("user"), .default(false), .default(0). Defaulted columns are optional in resource create inputs. |
.defaultSql(expr) |
Raw SQL default, emitted verbatim: .defaultSql("CURRENT_TIMESTAMP"). |
.references(table, column, actions?) |
Foreign key. actions takes onDelete and onUpdate — "cascade" | "set null" | "restrict" | "no action". Without one, deleting a referenced row fails with an FK violation. |
.enum(values, options?) |
Restricts a text or varchar column to a fixed set, narrowing the TS type to the union. { multiple: true } stores a comma-separated set — see Schema. |
timestamps(t)
Returns createdAt and updatedAt timestamp columns with .defaultSql("CURRENT_TIMESTAMP"). Spread it into a .table() column object.
.relation(fromTable, define)
Declares a relation used by context.db.query (Sola) and by resource include. define receives a chain with:
| Kind | Signature |
|---|---|
hasMany |
rel.hasMany(to, { from, to }) |
hasOne |
rel.hasOne(to, { from, to }) |
belongsTo |
rel.belongsTo(to, { from, to }) |
manyToMany |
rel.manyToMany(to, via, { from, to, pivotFrom, pivotTo }) — via is the pivot table; pivotFrom and pivotTo are the pivot columns referencing each side. |
.extend(previous)
Deep-merges another SchemaResult’s tables and relations into this builder. Existing builder columns win on collision (same as re-declaring via .table()). Relations are concatenated and deduped by identity. Call it anywhere in the chain:
const v1_1 = schema("1.1.0")
.extend(v1_0)
.table("post", (t) => ({ tags: t.text().nullable() }))
.build();Full guide: Schema → Extend a previous version.
.build()
Freezes and returns the SchemaResult you pass to .schema().
Type helpers
| Export | Description |
|---|---|
InferRouter<T> |
Extracts the oRPC router type from an Outer or BuiltOuter instance — the input to oRPC client creation. |
OuterAdminRouter |
{ _admin: AdminRouter } — the router shape for a type-safe admin-only client: createClient<OuterAdminRouter>({ baseUrl }). |
InferDB<T> |
Maps a schema’s tables to Kysely row types. |
AuthTables |
The table shape .auth() registers on the schema builder. |
FileTables |
The table shape schema().files() registers — the file table plus any attachTo pivots. |
OuterStorage |
The three-method object store contract accepted by new Outer({ storage }). |
OuterSecrets<T> |
The env accessor contract accepted by new Outer({ secrets }) — get, require, all. Build it with fromSchema / fromEnv / fromRecord. |
OuterKV |
The key/value store contract accepted by new Outer({ kv }) — the slice of unstorage’s Storage most apps use. See Key/value store. |
StandardSchemaV1 |
The Standard Schema interface fromSchema accepts (Zod, Valibot, ArkType). |
OuterRpcContext<TDB> |
The base procedure context: { headers, db, auth?, user, session, storage?, secrets?, kv? }. context.db is Kysely plus query (Sola) and transact(fn). |
context.user and context.session
After .auth(), both are resolved once per request and shared with every procedure, raw route, and permission check. Their types narrow from optional to SessionUser | null and UserSession | null, so you skip the ?. dance:
.procedure("user.me", (base) => base.handler(({ context }) => context.user))Both are null for anonymous callers, and always null when you never called .auth().