Skip to content

Next.js

Mount Outer inside a Next.js app with two route handlers, add a typed client, and call procedures from Server Components without a network hop.

Updated View as Markdown

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-lived next start process with a persistent filesystem is a zero-infra deploy. Mount the data directory — .outer/pglite by 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 }).

Navigation

Type to search…

↑↓ navigate↵ selectEsc close