outer.handle(request) is a plain Fetch handler, so it mounts into Next.js App Router route handlers with no adapter. Your whole backend — database, auth, typed RPC, generated CRUD — runs inside the same Next.js deployment, with no second service.
1. Install
bun add @outerjs/server @outerjs/sdk
bun add @electric-sql/pglite # for the embedded pglite() default (self-hosted deploys)2. Define the server
Create the Outer instance in a shared module. Next.js re-evaluates modules on HMR in dev, and pglite() allows only one live instance per data directory — so cache the instance and the migration run on globalThis:
// src/lib/outer.ts
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();
async function startOuter() {
// `.start()` builds the server and applies pending migrations
return new Outer({ name: "My API", baseUrl: process.env.NEXT_PUBLIC_APP_URL, db: pglite() })
.schema(v1_0)
.auth({ secret: process.env.AUTH_SECRET! })
.resource("post", {
permissions: { create: "authenticated", update: "owner", delete: "owner" },
ownerColumn: "userId",
})
.start();
}
const g = globalThis as unknown as { outer?: Awaited<ReturnType<typeof startOuter>> };
if (!g.outer) {
g.outer = await startOuter();
}
export const outer = g.outer;3. Mount the routes
Outer serves everything under /rpc/** and /api/auth/**, so two catch-all handlers cover it. Both only delegate: Next.js passes the full request path through, and Outer routes on it.
// src/app/rpc/[[...rest]]/route.ts
import { outer } from "@/lib/outer";
const handler = (req: Request) => outer.handle(req);
export {
handler as GET,
handler as POST,
handler as PUT,
handler as PATCH,
handler as DELETE,
handler as OPTIONS,
};// src/app/api/auth/[...all]/route.ts
import { outer } from "@/lib/outer";
const handler = (req: Request) => outer.handle(req);
export { handler as GET, handler as POST };If you enable .openapi(), add the same delegating handler at src/app/openapi.json/route.ts for GET and src/app/rest/[[...rest]]/route.ts for all methods.
4. Create a typed client
@outerjs/sdk gives you a typed RPC and auth client. The import type of the server module is erased at build time, so nothing server-side leaks into the client bundle:
// src/lib/client.ts
import { createClient } from "@outerjs/sdk";
import type { InferRouter } from "@outerjs/server";
import type { outer } from "./outer";
type Router = InferRouter<typeof outer>;
export const client = createClient<Router>({
baseUrl: process.env.NEXT_PUBLIC_APP_URL!,
})
.auth()
.build();// src/app/posts/new-post.tsx
"use client";
import { client } from "@/lib/client";
export function NewPost() {
return (
<button onClick={() => client.post.create({ title: "Hello from Next.js" })}>Create post</button>
);
}client.auth.* is the full Better Auth client, so you get client.auth.signIn.email({ email, password }), client.auth.useSession(), and the rest.
Call procedures during SSR
Outer runs in the same process as your Server Components, so SSR should never pay HTTP overhead. outer.client() returns an in-process router client — the same typed surface as the SDK, but each call invokes the procedure directly, with no serialization, fetch, or wire protocol.
Pass next/headers so permissions and context.auth see the caller’s session on every call:
// src/lib/api.server.ts
import "server-only";
import { headers } from "next/headers";
import { outer } from "./outer";
export const api = outer.client(() => headers());// src/app/posts/page.tsx
import { api } from "@/lib/api.server";
export default async function PostsPage() {
const posts = await api.post.list({});
return (
<ul>
{posts.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}If you share components between server and client rendering, go one step further with oRPC’s optimized SSR pattern. Assign outer.client(() => headers()) to globalThis.$client in a server-only module, import it from instrumentation.ts and your root layout, and have src/lib/client.ts fall back to it. One client import then calls in-process during SSR and over HTTP in the browser. Auth flows (client.auth.*) stay browser-only either way.
Deploy
- Self-hosted (VPS, Coolify, Docker). The
pglite()default works as-is: it writes to local disk, so any long-livednext startprocess with a persistent filesystem is a zero-infra deploy. Mount the data directory —.outer/pgliteby default — as a volume if you containerize. - Vercel. Serverless functions have no persistent disk, so swap
pglite()for a serverless Postgres dialect such as Neon. Nothing else changes:
import { neon } from "@neondatabase/serverless";
import { NeonDialect } from "kysely-neon";
new Outer({
name: "My API",
db: {
dialect: new NeonDialect({ neon: neon(process.env.DATABASE_URL!) }),
kind: "postgres", // Neon is real Postgres
},
});On serverless, run migrations from a deploy-time script instead of at cold start. templates/vercel-neon has a working setup, including a scripts/migrate.ts you can copy.
The same split applies to .files(): self-hosted can keep bytes on local disk via unstorage, while on Vercel they belong in Vercel Blob. Pass either as new Outer({ storage }).