Next.js
Outer's outer.handle(request) is a plain Fetch handler, so it mounts into Next.js App Router route handlers with no adapter. You get your whole backend — database, auth, typed RPC, generated CRUD — inside the same Next.js deployment, with no second service to run.
Install
bun add @outerjs/server @outerjs/sdk
bun add @electric-sql/pglite # for the embedded pglite() default (self-hosted deploys)
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, schema } from "@outerjs/server";
import { pglite } from "@outerjs/server/pglite";
const v1_0 = schema("1.0.0")
.table("post", (t) => ({
id: t.serial().primaryKey(),
title: t.text(),
userId: t.text(),
}))
.build();
function buildOuter() {
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",
})
.build();
}
const g = globalThis as unknown as { outer?: ReturnType<typeof buildOuter> };
if (!g.outer) {
g.outer = buildOuter();
await g.outer.migrator.migrateToLatest();
}
export const outer = g.outer;
Mount the routes
Outer serves everything under /rpc/** and /api/auth/**, so two catch-all route handlers cover it. Both just 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 (GET) and src/app/rest/[[...rest]]/route.ts (all methods) to expose the spec and the plain-JSON REST surface.
Client components
@outerjs/sdk gives you a typed RPC + 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 — client.auth.signIn.email({ email, password }), client.auth.useSession(), and so on.
Optimize 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, no fetch, no 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, you can go one step further with oRPC's optimized SSR pattern: assign outer.client(() => headers()) to globalThis.$client in a server-only module (imported from instrumentation.ts and your root layout), and have src/lib/client.ts fall back to it — one client import that calls in-process during SSR and over HTTP in the browser. Auth flows (client.auth.*) stay browser-only either way.
Deployment
- Self-hosted Next.js (VPS, Coolify, Docker) — the
pglite()default just works: 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. Everything else stays the same:
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 script at deploy time instead of at cold start — see templates/vercel-neon for a working setup, including a scripts/migrate.ts you can copy.