engineering

Building a SaaS Boilerplate with TanStack Start

Every SaaS project I’ve started has the same first week: implement auth, multi-tenancy, roles, permissions, audit logging. Not because any of it is interesting — it’s table stakes. But if you skip it or half-implement it, you pay for it later when a client asks why an org member can see another org’s data.

I’ve done this loop enough times that I built a boilerplate to stop doing it. It’s a TanStack Start application with better-auth, Drizzle, oRPC, Redis, and a full RBAC system. This post covers the stack choices, how the role system is structured, and how the pieces fit together.

Why TanStack Start

The obvious alternative is Next.js. I’ve used it for years and it works fine, but I’ve been moving toward TanStack’s ecosystem and wanted a framework that felt native to it rather than bolted on.

TanStack Start is an SSR framework built on top of TanStack Router — the same file-based routing and type-safe Link components, just with server rendering added. Everything in the router is fully typed: route params, search params, loader data. You don’t call useRouter() and cast the result, you import the route’s types directly.

The framework uses Vite 7 and runs a Hono server in production. The production server is minimal — Hono handles static asset serving with immutable cache headers and passes everything else to TanStack Start’s SSR handler. For development, there’s a devenv.sh config that spins up Postgres 16 and Redis 7 as nix packages so there’s nothing to install separately.

For linting and formatting I use Biome instead of ESLint + Prettier. Biome does both in a single pass, it’s written in Rust, and it’s fast enough that I don’t notice it running. Fewer configuration files, fewer dependencies to keep in sync.

Auth with better-auth

I’ve used NextAuth, Auth.js, and Lucia at various points. better-auth is the most recent and by far the cleanest to configure. The whole auth setup is one file:

export const auth = betterAuth({
  database: drizzleAdapter(db, { provider: "pg" }),
  emailAndPassword: { enabled: true },
  socialProviders: {
    google: {
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    },
  },
  plugins: [
    tanstackStartCookies(),
    admin({
      defaultRole: "user",
      adminRoles: ["super-admin", "admin"],
      ac,
      roles: platformRoles,
    }),
    organization({
      allowUserToCreateOrganization: true,
      organizationLimit: 5,
      creatorRole: "owner",
      membershipLimit: 50,
    }),
  ],
})

export type Session = typeof auth.$Infer.Session

The admin plugin adds platform-level role management — user banning, impersonation, role assignment. The organization plugin adds multi-tenancy: users can belong to multiple orgs, each session tracks an activeOrganizationId, and the creator of an org automatically becomes the owner. The tanstackStartCookies plugin handles cookie serialization in a way that’s compatible with TanStack Start’s SSR model.

The session type is inferred from the config rather than declared separately. Any change to the auth config — adding a field, enabling a plugin — propagates to Session automatically.

Database hooks are where activity logging plugs in. Sign-up, sign-in, and sign-out events are logged in the after hooks without any changes to the auth flow:

databaseHooks: {
  user: {
    create: {
      after: async (user) => {
        await logActivity({
          userId: user.id,
          action: "sign-up",
          resource: "user",
          resourceId: user.id,
          metadata: { email: user.email, name: user.name },
        }).catch(() => {})
      },
    },
  },
}

The .catch(() => {}) is intentional — a failed activity log should never break auth. This is instrumentation, not business logic.

Two role systems

The RBAC has two layers that represent different concerns.

Platform roles live on user.role in the database: super-admin, admin, and the default user. These are platform-wide. A super-admin can see all orgs, impersonate any user, manage billing — things that cut across org boundaries. The admin plugin handles the enforcement of these.

Org roles live in the member table: owner, admin, member. These are per-org. The same user can be an owner in one org and a member in another. The member.role column stores the role for that membership, and session.activeOrganizationId tracks which org is currently active.

export const resourceActions = {
  user: ["create", "list", "set-role", "ban", "impersonate", "delete", "get", "update"],
  session: ["list", "revoke", "delete"],
  "activity-log": ["list", "export"],
} as const

export const ownerRole = ac.newRole({
  user: ["create", "list", "set-role", "ban", "impersonate", "delete", "get", "update"],
  session: ["list", "revoke", "delete"],
  "activity-log": ["list", "export"],
})

export const adminRole = ac.newRole({
  user: ["create", "list", "ban", "get", "update"],
  session: ["list", "revoke"],
  "activity-log": ["list"],
})

export const memberRole = ac.newRole({
  user: ["get"],
  session: [],
  "activity-log": [],
})

The platform roles map to the same role definitions for the access control object:

export const platformRoles = {
  "super-admin": ownerRole,
  admin: adminRole,
  user: memberRole,
}

This means a super-admin automatically gets owner-level access within any org context. When the oRPC context is built, super-admins are given orgRole: "owner" without a database lookup.

oRPC — type-safe RPC over REST

I reached for oRPC instead of tRPC because it integrates with TanStack Query directly and has a cleaner middleware composition model. The pattern is a stack of procedure builders:

// Base — all procedures share this context type
export const publicProcedure = os.$context<ORPCContext>()

// Requires an authenticated session
export const protectedProcedure = publicProcedure.use((options) => {
  if (!options.context.session) {
    throw new ORPCError("UNAUTHORIZED", { message: "You must be signed in" })
  }
  return options.next({ context: { ...options.context, session: options.context.session } })
})

// Requires one of the specified org roles
export const requireRole = (...allowedRoles: AppRole[]) =>
  protectedProcedure.use((options) => {
    const role = options.context.orgRole
    if (!role || !allowedRoles.includes(role)) {
      throw new ORPCError("FORBIDDEN", { message: `Required role: ${allowedRoles.join(" or ")}` })
    }
    return options.next({ context: options.context })
  })

// Convenience aliases
export const adminProcedure = requireRole("owner", "admin")
export const ownerProcedure = requireRole("owner")

requirePermission goes one level deeper — it checks a specific resource/action pair against the role’s permission statements rather than just checking the role name:

export const requirePermission = <R extends keyof (typeof roles)["member"]["statements"]>(
  resource: R,
  actions: string[],
) =>
  protectedProcedure.use((options) => {
    const role = options.context.orgRole
    const roleObj = roles[role]
    const result = roleObj.authorize({ [resource]: actions })
    if (!result.success) {
      throw new ORPCError("FORBIDDEN", { message: result.error ?? "Permission denied" })
    }
    return options.next({ context: options.context })
  })

This is where the dynamic permission system (the custom roles stored in the database) would plug in — checking the rolePermission table instead of the static roles object. The infrastructure is there; it’s just not fully wired to the DB-level roles yet.

The isomorphic client is the part I find most useful. On the server (SSR), it calls the router directly without going over HTTP. On the client, it sends requests to /api/rpc. Same interface, same TypeScript types, different transports:

const getORPCClient = createIsomorphicFn()
  .server(() =>
    createRouterClient(router, {
      context: async () => {
        const headers = getRequestHeaders()
        const session = await auth.api.getSession({ headers })
        // ... resolve orgRole ...
        return { headers, session, orgRole }
      },
    }),
  )
  .client((): RouterClient<typeof router> => {
    const link = new RPCLink({ url: `${window.location.origin}/api/rpc` })
    return createORPCClient(link)
  })

export const orpc = createTanstackQueryUtils(client)

In any component, queries are just:

const { data } = useQuery(orpc.admin.listUsers.queryOptions())

No separate API routes to maintain, no fetch("/api/users") calls with manual error handling, no type casting. The router definition is the source of truth.

Database schema

The schema is six tables: user, organization, member, session, account, verification — the standard better-auth tables, managed by Drizzle.

On top of that are two boilerplate-specific tables:

activityLog — the audit trail. Every mutation logs an entry with userId, organizationId, action, resource, resourceId, metadata, ipAddress, and userAgent. The activity log page lets admins filter by resource, action, and user.

appRole + rolePermission — custom org-scoped roles. The idea is that beyond the three built-in roles (owner, admin, member), org owners can create custom roles and assign them specific resource/action permissions. The permission matrix UI in the boilerplate displays this as a grid of checkboxes. This is all implemented in the schema and seeded with system roles — the UI exists and works, though the oRPC procedures check the static role definitions rather than the DB-level ones at the moment.

Routing architecture

TanStack Router’s file-based routing uses layout routes to enforce auth at the boundary. The structure is:

_public.tsx       — Redirects to / if session present
  /auth/login
  /auth/register
  /org/create

_authenticated.tsx — Redirects to /auth/login if no session
  /$orgSlug/dashboard
  /$orgSlug/users
  /$orgSlug/roles
  /$orgSlug/permissions
  /$orgSlug/activity
  /$orgSlug/settings

The __root.tsx layout runs before everything else. It fetches the session via a server function, sets the locale from ?lang=en|id, and puts both on the router context. Every route downstream can read context.session without its own fetch — it’s already there from the root loader.

Within authenticated routes, some pages have an additional beforeLoad that checks orgRole and throws a redirect if the user doesn’t have the right role. For example, the users management page redirects member role users to the dashboard.

Caching

There’s a thin Redis caching layer for the two values that get read on almost every request but change infrequently.

Org metadata (fetched when resolving $orgSlug in the URL) is cached for 5 minutes:

const cacheKey = `org:slug:${orgSlug}`
const cached = await cacheGet(cacheKey)
if (cached) return cached
// ... fetch from DB ...
await cacheSet(cacheKey, row, 300)

Member role (fetched to build the oRPC context) is cached for 60 seconds. Role changes invalidate the cache immediately via cacheDel(). The TTL is intentionally short here — stale roles are a security issue, not just a staleness issue.

The Redis helpers degrade gracefully: if the connection is unavailable, they return null/skip the set and fall through to the database. The app stays up, just slower.

i18n

I use Paraglide JS for i18n instead of i18next or react-i18next. The difference is that Paraglide is compile-time — the translation functions are just typed TypeScript functions generated from the message files, not a runtime dictionary lookup. There’s no t() function that could return undefined, no missing key warnings at runtime, no bundle size penalty for loading all locales.

The boilerplate ships with English and Indonesian message files. Language is set via ?lang=en or ?lang=id on the URL and stored in the root loader context.

What you get

Out of the box, the boilerplate gives you:

  • Email/password and Google OAuth
  • Multi-org support with org switching
  • Platform roles (super-admin / admin / user) and org roles (owner / admin / member)
  • Custom role definitions with a per-resource permission matrix UI
  • Activity audit log for all auth events and admin actions
  • User management: create, ban, set role, impersonate, revoke sessions
  • Redis caching for org metadata and member roles
  • Dashboard with charts (Recharts) and data tables (TanStack Table)
  • Drag-and-drop (dnd-kit) on the permission matrix
  • Fully typed API layer (oRPC + TanStack Query)
  • English + Indonesian i18n out of the box
  • Biome for linting and formatting

What it doesn’t include: billing (no Stripe integration), email sending (no transactional email), or web framework extractors for the session beyond the built-in server function pattern. Those are the next things I’d add to make it production-complete for the typical SaaS case.

The codebase is at github.com/msdqn/tanstack-start-boilerplate. Clone it, run pnpm install && pnpm db:push && pnpm db:seed && pnpm dev, and you’re looking at a working multi-tenant app in about two minutes.