TanStack Start
TanStack Start's server routes hand you the raw Request and expect a Response back — exactly the shape of Outer's outer.handle(request). Two catch-all routes give you a full backend — database, auth, typed RPC, generated CRUD — inside the same Start app, and the router types flow end-to-end into your components.
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. Vite 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.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/**. TanStack Start's splat segment (a file named $.ts) catches the rest of the path, and since Start passes the full URL through, Outer routes on it directly:
// src/routes/rpc/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { outer } from "@/lib/outer";
const handler = ({ request }: { request: Request }) => outer.handle(request);
export const Route = createFileRoute("/rpc/$")({
server: {
handlers: {
GET: handler,
POST: handler,
PUT: handler,
PATCH: handler,
DELETE: handler,
OPTIONS: handler,
},
},
});
// src/routes/api/auth/$.ts
import { createFileRoute } from "@tanstack/react-router";
import { outer } from "@/lib/outer";
const handler = ({ request }: { request: Request }) => outer.handle(request);
export const Route = createFileRoute("/api/auth/$")({
server: {
handlers: {
GET: handler,
POST: handler,
},
},
});
If you enable .openapi(), add the same delegating handler at src/routes/openapi[.]json.ts (GET) and src/routes/rest/$.ts (all methods) to expose the spec and the plain-JSON REST surface.
Client
@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: import.meta.env.VITE_APP_URL,
})
.auth()
.build();
Use it anywhere in your components — with TanStack Query (which Start ships with), procedures slot straight into queryFn/mutationFn:
// src/routes/posts.tsx
import { useQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { client } from "@/lib/client";
export const Route = createFileRoute("/posts")({
component: Posts,
});
function Posts() {
const { data: posts } = useQuery({
queryKey: ["posts"],
queryFn: () => client.post.list({}),
});
return (
<ul>
{posts?.map((p) => (
<li key={p.id}>{p.title}</li>
))}
</ul>
);
}
client.auth.* is the full Better Auth client — client.auth.signIn.email({ email, password }), client.auth.useSession(), and so on.
Server functions and loaders
On the server, skip the network: Outer lives in the same process, so outer.client() gives you 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. Forward the incoming request's headers so permissions and context.auth see the caller's session:
// src/lib/api.server.ts
import { getRequest } from "@tanstack/react-start/server";
import { outer } from "@/lib/outer";
export const api = outer.client(() => getRequest().headers);
// src/lib/server-posts.ts
import { createServerFn } from "@tanstack/react-start";
import { api } from "@/lib/api.server";
export const listPosts = createServerFn().handler(() => api.post.list({}));
Route loaders call listPosts() and get the in-process path during SSR and an RPC back to the server function on client-side navigation — deduplication comes free with createServerFn.
Deployment
- Self-hosted (VPS, Coolify, Docker) — the
pglite()default just works: it writes to local disk, so any long-lived Node/Bun process with a persistent filesystem is a zero-infra deploy. Mount the data directory (.outer/pgliteby default) as a volume if you containerize. - Serverless/edge targets — no persistent disk, so swap
pglite()for a serverless-friendly Kysely dialect: Neon Postgres (seetemplates/vercel-neon) or a Cloudflare Durable Object (seetemplates/cloudflare). Run migrations from a deploy-time script instead of at cold start.