·Updated

TanStack Start + Supabase Auth: The Production Setup (2026)

Wire Supabase Auth into TanStack Start the production way: a cookie-correct @supabase/ssr server client, getClaims() for verified JWTs, beforeLoad guards, an RBAC middleware stack, and RLS from server functions. Pulled straight from our shipped TanStack Start Supabase kit.

Production Supabase Auth on TanStack Start needs four things most quickstarts skip: a cookie-correct @supabase/ssr server client wired to @tanstack/react-start/server, supabase.auth.getClaims() for a verified JWT on every request, beforeLoad guards plus a real RBAC middleware stack for server functions, and RLS-scoped Postgres RPCs so data access doesn't leak into hand-rolled account_id filters. We ship all of it today in the TanStack Start SaaS kit's Supabase variant, and every code sample below is pulled from that codebase, not sketched from the docs.

The Supabase official quickstart shows you read-only CRUD with no auth. The Convex integration punts auth to a third party. A couple of strong blog posts each cover half of the real setup: one nails the cookie-correct server client but skips route protection, the other has the right security mental model but never mentions Supabase or RLS. Nobody puts the whole production path, including MFA and role-based access, in one place. That's what this post is, and it's not theoretical: it's the auth layer of a shipping product.

This is written against TanStack Start on Vite + Nitro with @supabase/ssr current and React 19, tested July 2026 against our own kit's 1.0.0 release. TanStack Start is still young and moving fast, so date anything you copy from tutorials, including this one.

New to the framework? Start with TanStack Start vs Next.js for the mental model, then come back.

Why the quickstarts aren't enough

The gap isn't that good material doesn't exist, it's that no single source connects the pieces, and the pieces interlock:

  • Supabase's official quickstart: canonical and correct as far as it goes, but it's read-only CRUD with RLS on by default and no auth. No @supabase/ssr session, no createServerFn, no beforeLoad.
  • The Convex integration: ranks well, but has no RLS model at all and punts authentication to Clerk or WorkOS. Not a Supabase reference.
  • The strongest Supabase-specific blog posts: get the cookie-correct createServerClient right, which is the hardest part, but stop at "you're logged in." No route protection, no server-function authorization, no roles.
  • The best auth-guide posts: have the right security mental model (server function is the boundary, guard twice) but never touch Supabase, @supabase/ssr, or RLS.

Merge the security model with the Supabase specifics, add protected routing, RBAC, MFA, and RLS, and you have a setup that survives contact with real users. Let's build it.

The mental model: your server function is the security boundary

Start here, because getting this wrong is what makes "authenticated" apps leak data.

TanStack Start has no middleware.ts and no Next.js-style proxy sitting in front of every route. There's a global request middleware you register yourself, plus route-level beforeLoad guards, plus server functions. The critical thing to internalize: a beforeLoad guard protects navigation, not data. It decides whether a user is allowed to see a screen. It does nothing to protect the endpoints that screen calls.

Server functions (createServerFn) are directly-callable POST RPCs. Anyone who can reach your app can invoke one directly, with or without ever loading the route that normally calls it. They run through the request pipeline like any other POST, which is exactly why each one has to authorize itself. Nobody is doing it for them.

So you guard twice, and the two guards do different jobs:

  • beforeLoad guard = UX. Redirect anonymous users to sign-in before they see a protected screen. Fast, good experience, not a security control.
  • Server function auth = security. Every server function that reads or writes private data checks the user itself, or runs through middleware that does. This is the real boundary.

Our kit's Supabase variant encodes exactly this split, and it goes further than a single guard function: it's a composable RBAC middleware stack that every server function opts into by construction. That's the part most "here's how to add Supabase to TanStack Start" posts skip entirely, because they stop at "check if there's a user."

Setting up the @supabase/ssr server client

The server client is the load-bearing piece. It has to read auth cookies off the incoming request and write refreshed cookies back on the response, through TanStack Start's server surface, not Next.js helpers.

How to set up the Supabase SSR server client in TanStack Start:

  1. Create a Supabase project and grab the project URL and publishable (anon) key.
  2. Install @supabase/ssr and @supabase/supabase-js.
  3. Build a server client with createServerClient, passing getAll/setAll cookie accessors.
  4. Source those cookies from @tanstack/react-start/server (getRequest(), setCookie), parsed with parseCookieHeader.
  5. Gate cookieOptions.secure on NODE_ENV === 'production' so local http://localhost dev keeps working.

Here's the client, unchanged from packages/supabase/src/clients/server-client.server.ts in the kit:

import { createServerClient, parseCookieHeader } from '@supabase/ssr';
import { getRequest, setCookie } from '@tanstack/react-start/server';
import { type Database } from '../database.types';
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
export function getSupabaseServerClient<GenericSchema = Database>() {
const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.publicKey, {
cookieOptions: {
secure: process.env.NODE_ENV === 'production',
},
cookies: {
getAll() {
const header = getRequest().headers.get('cookie') ?? '';
return parseCookieHeader(header).map(({ name, value }) => ({
name,
value: value ?? '',
}));
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) =>
setCookie(name, value, options),
);
},
},
});
}

Two things matter here and both are easy to get wrong.

First, use the getAll/setAll cookie interface, not the older per-cookie get/set/remove. Supabase splits the session across several cookies (it chunks large tokens). The setAll shape hands you the full set to write in one pass. The single-cookie API is where the classic "only the last cookie survives" bug comes from, more on that in the footguns section.

Second, the cookie source is @tanstack/react-start/server, read straight off getRequest(). If you're following an older tutorial you'll see vinxi/h3 or a bespoke getWebRequest() shim. Those predate the move to the Vite plugin and Nitro. When something in a guide you're reading doesn't line up, check its date first.

Nothing here is wrapped in createServerOnlyFn. It doesn't need to be: getSupabaseServerClient lives inside .server.ts files, which TanStack Start's compiler already strips from the client bundle. The naming convention is the boundary.

Verified auth: getClaims(), not getUser(), and never getSession()

This is the part every other "Supabase on TanStack Start" post gets wrong, including an earlier draft of this one. The kit doesn't call getUser(). It calls getClaims().

Concretely: cookies arrive with every request and a client can set cookie values to whatever they like. getSession() trusts them. getClaims() doesn't; it cryptographically verifies the JWT and hands you its claims (sub, email, aal, app_metadata, is_anonymous) without necessarily leaving the server process. That's the entire point, and it's why the kit standardizes on it everywhere a user needs verifying, not just in one place.

Here's requireUser, the function every authenticated code path in the kit calls through, from packages/supabase/src/require-user.ts:

export async function requireUser(
client: SupabaseClient,
options?: { verifyMfa?: boolean; next?: string; token?: string },
): Promise<SuccessResult | AuthenticationErrorResult | MultiAuthErrorResult> {
const { data, error } = await client.auth.getClaims(options?.token);
if (!data?.claims || error) {
return {
data: null,
error: new AuthenticationError(),
redirectTo: getRedirectTo('/auth/sign-in', options?.next),
};
}
const { verifyMfa = true } = options ?? {};
if (verifyMfa) {
const requiresMfa = await checkRequiresMultiFactorAuthentication(client);
if (requiresMfa) {
return {
data: null,
error: new MultiFactorAuthError(),
redirectTo: getRedirectTo('/auth/verify', options?.next),
};
}
}
const role = data.claims.app_metadata?.role;
return {
error: null,
data: {
id: data.claims.sub,
email: data.claims.email,
phone: data.claims.phone,
aal: data.claims.aal,
amr: data.claims.amr,
is_anonymous: data.claims.is_anonymous || false,
is_superadmin: role === 'super-admin' && data.claims.aal === 'aal2',
},
};
}

One function, three jobs: verify the JWT, check whether the session needs to step up to MFA, and derive is_superadmin from claims (role === 'super-admin' && aal === 'aal2', so a compromised aal1 session can never pass an admin check even if the role claim is right). Every guard and every piece of protected server code in the kit goes through this, so "am I logged in" and "do I need MFA" are answered in exactly one place.

One fetch at the root, not one per route

The root route resolves the session once, in apps/web/src/routes/__root.tsx, and puts it on router context. Child routes read context.user instead of re-fetching:

export const Route = createRootRouteWithContext<RouterContext>()({
// Single token-refresh point: the cookie-writing server client verifies the
// JWT via getClaims() and persists any rotated refresh cookie. Child routes
// read context.user without re-fetching. This is UX-only — RLS still
// enforces data access.
beforeLoad: async () => {
const [user, locale, theme] = await Promise.all([
fetchSession(),
detectLocale(),
getServerTheme(),
]);
return { user, locale, theme };
},
// ...
});

fetchSession is a thin server function wrapping the same getClaims() call, returning null instead of throwing when there's no session so the app boots cleanly for anonymous visitors:

export const fetchSession = createServerFn({ method: 'GET' }).handler(
async (): Promise<Session> => {
const client = getSupabaseServerClient();
const { data, error } = await client.auth.getClaims();
if (error || !data?.claims) return null;
const claims = data.claims;
const role = claims.app_metadata?.role;
return {
id: claims.sub,
email: claims.email,
aal: claims.aal,
amr: claims.amr,
is_superadmin: role === 'super-admin' && claims.aal === 'aal2',
is_anonymous: claims.is_anonymous ?? false,
};
},
);

This is the piece that replaces proxy.ts/middleware.ts from the Next.js world: there's no file that runs before every route, so the root beforeLoad is the one place session resolution and refresh happen for navigation. Note the comment in the actual source: this is explicitly UX-only. It exists so the sign-in state renders correctly and instantly across the tree, not to authorize data access. That job belongs to RLS and the server-function middleware below, every time, no exceptions.

Protecting routes with beforeLoad

The authenticated layout route is where navigation actually gets locked down, and it does three things in sequence: check the session, check MFA assurance level, then resolve the active workspace. From apps/web/src/routes/_authenticated/route.tsx:

export const Route = createFileRoute('/_authenticated')({
beforeLoad: async ({ context, location }) => {
if (!context.user) {
throw redirect({
href: `${pathsConfig.auth.signIn}?next=${encodeURIComponent(location.href)}`,
});
}
const requiresMfa = await fetchRequiresMfa();
if (requiresMfa) {
throw redirect({ href: pathsConfig.auth.verifyMfa });
}
const workspace = await fetchWorkspace();
return { workspace };
},
component: AuthenticatedLayout,
});

fetchRequiresMfa is real, not aspirational: a user with MFA enrolled gets an aal1 session immediately after password sign-in, and checkRequiresMultiFactorAuthentication (packages/supabase/src/check-requires-mfa.ts) compares nextLevel against currentLevel from getAuthenticatorAssuranceLevel() to decide whether they need to step up before they see anything behind the gate. Most "add Supabase auth" tutorials never mention AAL2 step-up at all; it's not optional if you support MFA, and the kit wires it into the same guard as everything else.

The admin gate takes a different, more paranoid shape. From apps/web/src/routes/admin/route.tsx:

export const Route = createFileRoute('/admin')({
beforeLoad: ({ context, location }) => {
if (!context.user) {
throw redirect({
href: `${pathsConfig.auth.signIn}?next=${encodeURIComponent(location.href)}`,
});
}
if (!context.user.is_superadmin) {
throw notFound();
}
},
// ...
});

Anonymous visitors go to sign-in. Authenticated non-admins get a 404, not a redirect and not a 403. That's deliberate: a redirect or a "forbidden" page confirms /admin exists and gates on something; a 404 gives an attacker nothing to probe. Small detail, and one you won't get from copying a generic auth guard off a blog post.

One gotcha that bites people wrapping guard logic in try/catch: TanStack Router signals a redirect and a not-found by throwing. If you catch errors around a server-function call and log-and-swallow them, you'll eat the redirect too. Rethrow anything that's isRedirect() or isNotFound() before you handle a real error, which is exactly what the kit's errorMiddleware does at the function-middleware layer (next section).

The RBAC middleware stack: the part quickstarts skip entirely

This is the boundary that actually matters, and it's the step that doesn't exist in a bare @supabase/ssr setup because there's no server-function layer to hang it on.

Rather than paste an auth check into every handler, the kit factors authorization into composable function middleware, from packages/function-middleware/src/middleware.server.ts:

export const errorMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
try {
return await next();
} catch (error) {
if (isRedirect(error) || isNotFound(error)) throw error;
const message = error instanceof Error ? error.message : 'Internal Server Error';
throw new Error(message);
}
},
);
export const authMiddleware = createMiddleware({ type: 'function' }).server(
async ({ next }) => {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (!auth.data) {
throw redirect({ to: auth.redirectTo });
}
return next({ context: { user: auth.data } });
},
);

errorMiddleware normalizes thrown errors to a clean message string, mirroring what next-safe-action does in our Next.js kit, while letting redirect()/notFound() control flow pass through untouched so navigation keeps working. authMiddleware is requireUser wired into the server-function pipeline: no user, no context.user, full stop.

On top of that base, the kit ships purpose-built gates for multi-tenant SaaS:

  • adminMiddleware: requires context.user.is_superadmin.
  • teamAccountMiddleware: validates a uuid accountId in the action input with Zod, then asserts membership via the has_role_on_account Postgres RPC, called through the RLS-authorized client.
  • withMinRole(role): role-hierarchy check. has_role_on_account does an exact match, which can't express "at least member," so this combines has_more_elevated_role OR has_same_role_hierarchy_level to do it correctly.
  • withFeaturePermission(permission): fine-grained feature gating via the has_permission RPC, for permissions narrower than a role (members.manage, for example).

These compose as literal tuples at the call site, not through a factory function, because TanStack Start's compiler needs to see a literal createServerFn(...) call to know which code is server-only and strip it from the client bundle:

export const authFunctionMiddleware = [errorMiddleware, authMiddleware] as const;
export const teamAccountFunctionMiddleware = [errorMiddleware, teamAccountMiddleware] as const;
export const removeMemberFunction = createServerFn({ method: 'POST' })
.middleware(teamAccountFunctionMiddleware)
.validator(RemoveMemberSchema)
.handler(async ({ data, context }) => {
// context.accountId is guaranteed and membership-checked
});

Layer an extra gate by spreading a tuple, for example requiring a specific feature permission on top of team membership:

.middleware([...authFunctionMiddleware, withFeaturePermission('members.manage')])

Guard the navigation with beforeLoad, guard the data with function middleware, and layer role and permission checks the same way you'd layer any other middleware. Two locks minimum, more when the action needs them.

RLS with the authenticated client, called through RPCs

Here's the payoff that makes Supabase and TanStack Start a genuinely good pairing: RLS applies automatically from your server functions, with no manual token juggling, and the kit's real feature code leans on Postgres RPCs instead of hand-filtering by account_id in TypeScript.

Because the @supabase/ssr server client carries the user's session from cookies, every query and RPC call it makes goes to Postgres as that user. Your RLS policies see auth.uid() and filter accordingly. fetchWorkspace, from apps/web/src/lib/server/active-workspace.functions.ts, is a real example, not a toy:

export const fetchWorkspace = createServerFn({ method: 'GET' })
.middleware([errorMiddleware])
.handler(async (): Promise<WorkspaceShape> => {
const client = getSupabaseServerClient();
const auth = await requireUser(client);
if (auth.error) {
throw redirect({ href: auth.redirectTo });
}
const [workspaceResult, accountsResult] = await Promise.all([
client.rpc('active_account_workspace'),
client.from('user_accounts').select('*').order('name', { ascending: true }),
]);
if (workspaceResult.error) throw workspaceResult.error;
// ...
return { account: workspaceResult.data[0], accounts: accountsResult.data, user: auth.data };
});

No .eq('account_id', ...) anywhere in that function. active_account_workspace is a SECURITY DEFINER RPC that resolves the caller's active account server-side, and user_accounts is RLS-scoped to rows the calling user can actually see. Team member lists work the same way (get_active_account_members / get_active_account_invitations RPCs), and switching the active account goes through a set_active_account RPC that validates membership itself rather than trusting a client-supplied ID.

The pattern generalizes: for anything account-scoped, push the "which rows can this user see" logic into RLS policies and RPCs, and let the server function be a thin caller. It's less code, and it means a missing beforeLoad guard or a bug in teamAccountMiddleware still can't leak rows, the database refuses regardless. For policy patterns and the mistakes that make RLS slow or leaky, see Row Level Security in the TanStack Start Supabase kit and, more generally, Supabase RLS best practices.

The service-role (admin) client is the deliberate exception: it bypasses RLS entirely, for background jobs and privileged operations. Reach for it explicitly, validate authorization by hand every time, and keep it out of any code path a browser request can reach.

Request middleware: CSRF and security headers, not session refresh

TanStack Start's global request middleware, registered in apps/web/src/start.ts, is where cross-cutting concerns that apply to every request live: CSRF protection scoped to server functions, and security headers on every response.

const csrfMiddleware = createCsrfMiddleware({
filter: (ctx) => ctx.handlerType === 'serverFn',
});
const securityHeadersMiddleware = createMiddleware({ type: 'request' }).server(
async ({ next }) => {
const result = await next();
setResponseHeader('X-Frame-Options', 'DENY');
setResponseHeader('X-Content-Type-Options', 'nosniff');
setResponseHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
if (process.env.NODE_ENV === 'production') {
setResponseHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return result;
},
);
export const startInstance = createStart(() => ({
requestMiddleware: [csrfMiddleware, securityHeadersMiddleware],
}));

Worth being precise here, because it's easy to assume this is also where you'd refresh the Supabase session on every request, the way middleware.ts does in Next.js. It isn't. In this kit, session verification and refresh happen once, at the root beforeLoad, via fetchSession(), and the cookie-writing server client persists any rotated token through setAll at that point. start.ts stays focused on CSRF and headers. If your app needs the session available to genuinely global, non-route logic, that's a legitimate reason to add a request-middleware session check, but it's an addition to this pattern, not the default the kit ships with.

Production footguns nobody collects in one place

These turn a working local demo into a support ticket. Dodge them on the way in instead of debugging them at midnight.

  • The multi-cookie collapse. Set Supabase auth cookies one at a time and you'll lose all but the last, because Supabase writes several chunked cookies and a naive single-cookie setter clobbers them. The fix is the getAll/setAll interface shown above, which writes the whole set in one pass. This is the number-one reason "login works but the session vanishes on refresh." (TanStack Start issues #5464, #2680.)
  • Can't read a cookie you just set in the same request. Inside one createServerFn invocation, a cookie written via setCookie isn't visible to a subsequent read in that same request. Don't set-then-read; carry the value in a local variable instead. (Issue #5615.)
  • getUser() on every guard, on every navigation. Even before you consider getClaims(), calling getUser() from multiple guards on the same navigation multiplies network round trips to Supabase Auth for no benefit. Resolve the session once, at the root, the way fetchSession does, and pass it down through router context.
  • Auth cookie doesn't persist in Safari. Safari's stricter cookie handling can drop cookies with the wrong SameSite/Secure/domain settings. Check your cookie options and your local vs production domain setup if sessions survive in Chrome but not Safari. (Issue #3492.)
  • Forgetting the AAL2 check entirely. If you support MFA (and you should, for anything handling money or sensitive data), a session that's merely "authenticated" isn't the same as one that's cleared its second factor. Skip the aal/amr check and an aal1 session sails past guards that assume MFA already happened.

None of these are exotic. They're the difference between a quickstart that demos well and a setup you can put real users on.

Fast feedback for the auth code you just wrote: oxlint, oxfmt, TypeScript 7

None of the guard, middleware, or RLS code above is useful if you can't verify it fast. Auth code is exactly the kind of thing you iterate on: tighten a guard, add a permission check, adjust a claim shape, then check it didn't break anything. The kit's toolchain is built for that loop, and it's not the usual eslint + prettier + tsc stack.

package.json at the repo root wires up oxlint for linting and oxfmt for formatting, both from the Rust-based oxc toolchain, and pins typescript@7.0.2, the Go-ported compiler (shipped as per-platform native binaries, @typescript/typescript-darwin-arm64 and friends, instead of the old JS-hosted tsc). The combined check is one command:

"healthcheck": "oxlint --fix && oxfmt && pnpm run typecheck && manypkg fix"

The practical effect: linting and formatting a monorepo this size finishes in a fraction of the time the ESLint/Prettier equivalent takes, and tsc --noEmit against TypeScript 7's native compiler checks the type-level guarantees in this post, that context.user is narrowed after a guard, that requireUser's discriminated union is exhaustively handled, in seconds instead of tens of seconds on a cold run. That's not just a nicer pnpm dev experience. It's the difference between a coding agent (Claude Code included) running typecheck and lint:fix after every edit to your auth middleware and actually getting fast, trustworthy feedback, versus a check so slow it gets skipped. Fast, deterministic verification is what makes it safe to let an agent touch authMiddleware or a withFeaturePermission gate at all.

The MakerKit TanStack Start Supabase kit

You can hand-roll everything above, and now you know exactly what "everything" is: a cookie-correct server client, getClaims()-verified sessions resolved once at the root, beforeLoad guards for navigation and MFA step-up, a composable RBAC middleware stack for server functions, and RLS-backed RPCs for data. That's not a small amount of plumbing to get right and keep right as the app grows.

Our TanStack Start SaaS kit ships this exact architecture today, on real Supabase Auth, not a hypothetical variant. Every code sample in this post is pulled directly from that codebase: getSupabaseServerClient, requireUser, the authMiddleware/teamAccountMiddleware/withMinRole/withFeaturePermission stack, MFA-aware guards, and the RLS-scoped RPCs behind team workspaces. It ships alongside Drizzle and Prisma variants of the same kit for teams who want a different data layer with the same TanStack Start foundation; see the installation docs to get started, or read Introducing the TanStack Start SaaS Starter Kit for the full pitch across all three.

Quick Recommendation

This production setup is best for:

  • Teams building a real SaaS on TanStack Start who need auth that survives direct endpoint calls, not just a demo.
  • Anyone who wants Supabase RLS as a real second line of defense behind app-level checks, plus roles, permissions, and MFA out of the box.

Skip the full setup if:

  • You're building a throwaway prototype or an internal read-only dashboard with no private data.
  • You haven't yet decided on the framework; read the hub comparison first.

Our pick: Cookie-correct @supabase/ssr server client, getClaims() resolved once at the root for every authorization decision, beforeLoad for navigation and MFA step-up, and function middleware plus RLS-backed RPCs for data. Guard navigation once, guard data with a real middleware stack, and let Postgres be the backstop.

Frequently Asked Questions

getClaims(), getUser(), or getSession() on the server in TanStack Start?
Use getClaims(). It cryptographically verifies the JWT, and for projects on Supabase's asymmetric JWT signing keys it does so locally without a network round trip, falling back to a network check only for legacy HS256 projects. getUser() is also verified but always makes a network call on every invocation. getSession() reads the session out of cookie storage without verifying it at all, which is spoofable on the server and must never gate access.
Is a beforeLoad guard enough to secure my data?
No. A beforeLoad guard protects navigation, not data. Server functions are directly-callable POST endpoints that a client can invoke without ever loading the route, so each one that touches private data must authorize itself, typically through function middleware. Guard navigation with beforeLoad, and guard data inside the server function with an auth middleware stack plus RLS.
Does RLS work automatically from a TanStack Start server function?
Yes. The @supabase/ssr server client reads the user's session from cookies and sends that access token with every query and RPC call, so RLS policies see auth.uid() and filter rows automatically. You don't pass a token or set an Authorization header. The service-role client is the exception: it bypasses RLS by design, so validate authorization manually when you use it.
Does the MakerKit TanStack Start kit support Supabase Auth?
Yes, as of the kit's 1.0.0 release. The Supabase variant ships a cookie-correct @supabase/ssr server client, getClaims()-based session resolution, beforeLoad guards with MFA step-up, an RBAC middleware stack (authMiddleware, teamAccountMiddleware, withMinRole, withFeaturePermission), and RLS-scoped Postgres RPCs for team workspaces. It ships alongside Drizzle and Prisma variants of the same TanStack Start foundation.
Do I need a middleware.ts file like Next.js for Supabase sessions?
No. TanStack Start has no middleware.ts. Global request-level concerns like CSRF and security headers go in the request middleware registered in start.ts. Session verification and refresh happen once, in the root route's beforeLoad, via a getClaims() call whose cookie-writing server client persists any rotated token. Route-level protection lives in beforeLoad, and data-level protection lives in server-function middleware plus RLS.
Why does my Supabase session disappear after a page refresh?
Almost always the multi-cookie collapse: Supabase writes several chunked auth cookies, and a single-cookie setter keeps only the last one. Use the getAll/setAll cookie interface on createServerClient, not the older get/set/remove. Also check Safari cookie settings (SameSite, Secure, domain) if it only breaks in Safari.
How does role-based access control work with Supabase in this kit?
Through Postgres RPCs called from the RLS-authorized server client, wrapped in function middleware. teamAccountMiddleware checks membership via has_role_on_account, withMinRole(role) combines has_more_elevated_role and has_same_role_hierarchy_level for at-least-this-role checks, and withFeaturePermission(permission) checks fine-grained permissions via has_permission. Compose them onto a server function as middleware tuples.
What lint, format, and typecheck tooling does the kit use?
oxlint for linting and oxfmt for formatting, both from the Rust-based oxc toolchain, plus TypeScript 7 (the Go-ported compiler, distributed as per-platform native binaries) for type checking. A single healthcheck script runs oxlint --fix, oxfmt, typecheck, and manypkg fix. The point is speed: fast, deterministic verification after every change, which matters as much for a human iterating on auth middleware as it does for a coding agent checking its own diff.

Next steps

You now have the whole production path: cookie-correct server client, verified getClaims(), double-guarded routes and data, RBAC middleware, MFA step-up, and RLS as the backstop. If you're weighing the framework itself, read TanStack Start vs Next.js. For RLS depth, go to Supabase RLS best practices or the kit's own Row Level Security docs. If you picked Better Auth instead of Supabase Auth, the equivalent wiring is covered in Better Auth on TanStack Start. And if you'd rather start from a kit that already ships this architecture on Supabase, Drizzle, or Prisma, take a look at the TanStack Start SaaS kit.

Some other posts you might like...