docs(tanstack): add a proper SSR quick start with cookie-based auth (#48105)

## Summary

TanStack Start's quickstart only ever wired up an anonymous
`supabase-js` client — no cookies, no `@supabase/ssr`, no auth. This
ports the real `@supabase/ssr` client/server split and password-based
auth flow (already shipped in `apps/ui-library`) into the quickstart and
adds a matching tab to the SSR guide.

## Where this changed

- `apps/docs/content/guides/getting-started/quickstarts/tanstack.mdx` —
quickstart now installs the cookie-based auth flow via the Supabase UI
Library registry and queries data through the SSR-aware server client.
- `apps/docs/content/guides/auth/server-side/creating-a-client.mdx` —
new TanStack Start tab (client/server setup + protecting routes).
- `examples/auth/tanstack/` (new) — source files backing the
`$CodeSample` snippets above, ported from `apps/ui-library`'s registry.

## Test plan

- [x] Scaffolded a real TanStack Start app and ran the quickstart
commands end-to-end
- [x] Confirmed SSR loader + protected-route redirect work as documented
- [x] `pnpm lint:mdx` and `pnpm build:guides-markdown` pass

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added TanStack Start SSR setup examples for Supabase, including
browser and server client helpers with cookie-based session support.
* Included a protected route example that checks authentication on the
server and redirects unauthenticated users to the login page.
  * Added a server-side claims fetch helper for authorization checks.
* **Documentation**
* Expanded the “creating a client” guide with TanStack Start-specific
route protection and environment variable examples.
* Updated the TanStack Start quickstart to use the official CLI and
refined server-side authorization guidance.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
Ali Waseem authored and GitHub committed 2026-07-22 12:00:05 -06:00
1 parent 2d5ec97df8
commit ac714e81ba
6 files changed
+211 -24

No files matched your search

@@ -159,6 +159,14 @@ SUPABASE_URL=supabase_project_url
SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key
```
</TabPanel>
<TabPanel id="tanstack" label="TanStack Start">
```bash .env.local
VITE_SUPABASE_URL=supabase_project_url
VITE_SUPABASE_PUBLISHABLE_KEY=supabase_publishable_key
```
</TabPanel>
</Tabs>
@@ -510,7 +518,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
)
// Use `supabase` here for server-side work, e.g. await supabase.auth.getUser()
// Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims()
// Return the environment variables so the browser can create its own client.
return json(
@@ -674,7 +682,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
)
// Use `supabase` here for server-side work, e.g. await supabase.auth.getUser()
// Use `supabase` here for server-side work, e.g. await supabase.auth.getClaims()
// Return the env vars so the browser can create its own client.
return data(
@@ -823,6 +831,81 @@ language="typescript"
</TabPanel>
</Tabs>
</TabPanel>
<TabPanel id="tanstack" label="TanStack Start">
### Write utility functions to create Supabase clients
TanStack Start renders matched routes on the server by default, so `beforeLoad` and `loader` run server-side on the initial request. Unlike Next.js, this means you don't need a proxy or middleware layer to keep sessions fresh — the server client reads and writes the session cookie directly on each request.
Create a `lib/supabase` folder at the root of your project, or inside the `./src` folder if you are using one, then add a file for each type of client:
1. **Create a browser client in `lib/supabase/client.ts`.** Use it to access Supabase from components that run in the browser.
2. **Create a server client in `lib/supabase/server.ts`.** Use it to access Supabase from loaders, server functions, and other code that runs only on the server.
<$Partial path="auth_methods.mdx" />
Copy the lib utility functions below into each file:
<div className="mt-12">
<$CodeTabs>
<$CodeSample
path="/auth/tanstack/lib/supabase/client.ts"
meta="name=lib/supabase/client.ts"
language="typescript"
/>
<$CodeSample
path="/auth/tanstack/lib/supabase/server.ts"
meta="name=lib/supabase/server.ts"
language="typescript"
/>
</$CodeTabs>
</div>
### Protecting routes
TanStack Start has no global middleware layer, so protect each route explicitly.
To protect your routes:
1. Write a server function, `fetchClaims`, that calls `supabase.auth.getClaims()` and returns the claims, or `null` if the session isn't valid.
1. Call `fetchClaims` from a layout route's `beforeLoad` hook — for example, `_protected.tsx` — before any nested route renders, and redirect to `/login` when it returns `null`.
<Admonition type="danger">
Skipping the check inside the server function exposes private data to unauthenticated users. `beforeLoad` runs on the server for the initial request and on the client for later navigation, but either way it only gates the route's render — it doesn't stop the server function from being called directly. Because there's no proxy re-checking every request, the server function is the only checkpoint that always runs, so it must call `supabase.auth.getClaims()` to authorize the request itself.
</Admonition>
`getClaims()` validates the JWT signature on every call, the same check the Next.js Proxy relies on. Calling it inside the server function gives TanStack Start's per-route check that same guarantee, because the function runs on every request to a protected route.
<div className="mt-12">
<$CodeTabs>
<$CodeSample
path="/auth/tanstack/lib/supabase/fetch-claims-server-fn.ts"
meta="name=lib/supabase/fetch-claims-server-fn.ts"
language="typescript"
/>
<$CodeSample
path="/auth/tanstack/routes/_protected.tsx"
meta="name=routes/_protected.tsx"
language="typescript"
/>
</$CodeTabs>
</div>
Any other server function that returns or mutates private data needs this same check. Don't rely on a route being nested under `_protected` alone.
## Congratulations
You're done! To recap, you've successfully:
- Set up a Supabase client utility to call Supabase from a browser component. You can use this if you need to call Supabase from the browser, for example to set up a realtime subscription.
- Set up a server client utility to call Supabase from loaders and server functions.
- Protected a route with `beforeLoad`, backed by a server function that authorizes the request itself.
You can now use any Supabase features from your client or server code!
</TabPanel>
</Tabs>
@@ -11,7 +11,7 @@ breadcrumb: 'Framework Quickstarts'
Create a TanStack Start app using the official CLI.
```bash
npm create @tanstack/start@latest my-app -- --package-manager npm --toolchain biome
npx @tanstack/cli@latest create my-app
```
## 4. Install Agent Skills (optional)
@@ -24,55 +24,87 @@ To install, run the following command in the root of your project:
npx skills add supabase/agent-skills
```
## 5. Install the Supabase client library
## 5. Install the Supabase client libraries
The fastest way to get started is to use the `supabase-js` client library which provides a convenient interface for working with Supabase from a TanStack Start app.
Navigate to the TanStack Start app and install `supabase-js`.
Navigate to the TanStack Start app and install `supabase-js` and `@supabase/ssr`, the helper package that manages cookie-based sessions for server-side rendering.
```bash
cd my-app && npm install @supabase/supabase-js
cd my-app && npm install @supabase/supabase-js @supabase/ssr
```
## 6. Declare Supabase environment variables
Create a `.env` file in the root of your project and populate with your Supabase connection variables that you can get from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true):
Create a `.env.local` file in the root of your project and populate it with your Supabase connection variables. Get the values from the helper below, or [from the project **Connect** panel](/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=tanstack).
<Button variant="primary" asChild>
<a href="/dashboard/project/_?showConnect=true">Open Connect panel</a>
<a href="/dashboard/project/_?showConnect=true&connectTab=frameworks&framework=tanstack">
Open Connect panel
</a>
</Button>
```text name=.env
```text name=.env.local
VITE_SUPABASE_URL=<SUBSTITUTE_SUPABASE_URL>
VITE_SUPABASE_PUBLISHABLE_KEY=<SUBSTITUTE_SUPABASE_PUBLISHABLE_KEY>
```
<$Partial path="api_settings.mdx" variables={{ "framework": "", "tab": "" }} />
<$Partial path="api_settings.mdx" variables={{ "framework": "tanstack", "tab": "frameworks" }} />
## 7. Create a Supabase client utility
## 7. Create Supabase client utilities
Create a new file at `src/utils/supabase.ts` to initialize the Supabase client.
TanStack Start needs two Supabase clients: a browser client for components that run in the browser, and a server client for loaders and server functions. Create a `src/lib/supabase` folder with a file for each client.
```ts name=src/utils/supabase.ts
import { createClient } from '@supabase/supabase-js'
```ts name=src/lib/supabase/client.ts
/// <reference types="vite/types/importMeta.d.ts" />
import { createBrowserClient } from '@supabase/ssr'
export const supabase = createClient(
import.meta.env.VITE_SUPABASE_URL,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY
)
export function createClient() {
return createBrowserClient(
import.meta.env.VITE_SUPABASE_URL!,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!
)
}
```
## 8. Query data from the app
```ts name=src/lib/supabase/server.ts
import { createServerClient } from '@supabase/ssr'
import { getCookies, setCookie, setResponseHeader } from '@tanstack/react-start/server'
Replace the contents of `src/routes/index.tsx` with the following code to add a loader function that fetches the instruments data and displays it on the page.
export function createClient() {
return createServerClient(
process.env.VITE_SUPABASE_URL!,
process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return Object.entries(getCookies()).map(([name, value]) => ({ name, value }))
},
setAll(cookies, headers) {
cookies.forEach(({ name, value, options }) => {
setCookie(name, value, options)
})
Object.entries(headers).forEach(([name, value]) => {
setResponseHeader(name, value)
})
},
},
}
)
}
```
## 8. Query Supabase data from TanStack Start
Replace the contents of `src/routes/index.tsx` with the following to add a loader that queries the `instruments` table through the server client. The loader runs on the server, so the data is part of the initial server-rendered response.
```tsx name=src/routes/index.tsx
import { createFileRoute } from '@tanstack/react-router'
import { supabase } from '../utils/supabase'
import { createClient } from '@/lib/supabase/server'
export const Route = createFileRoute('/')({
loader: async () => {
const supabase = createClient()
const { data: instruments } = await supabase.from('instruments').select()
return { instruments }
},
@@ -102,7 +134,8 @@ npm run dev
## Next steps
- Learn how to [protect routes and check sessions](/docs/guides/auth/server-side/creating-a-client?queryGroups=framework&framework=tanstack) with the server client
- Set up a complete [login and sign-up flow](/ui/docs/tanstack/password-based-auth) from the Supabase UI Library
- Explore [drop-in UI components](/ui) for your Supabase app
- Set up [Auth](/docs/guides/auth) for your app
- [Insert more data](/docs/guides/database/import-data) into your database
- Upload and serve static files using [Storage](/docs/guides/storage)
@@ -0,0 +1,9 @@
/// <reference types="vite/types/importMeta.d.ts" />
import { createBrowserClient } from '@supabase/ssr'
export function createClient() {
return createBrowserClient(
import.meta.env.VITE_SUPABASE_URL!,
import.meta.env.VITE_SUPABASE_PUBLISHABLE_KEY!
)
}
@@ -0,0 +1,14 @@
import { createServerFn } from '@tanstack/react-start'
import { createClient } from '@/lib/supabase/server'
export const fetchClaims = createServerFn({ method: 'GET' }).handler(async () => {
const supabase = createClient()
const { data, error } = await supabase.auth.getClaims()
if (error) {
return null
}
return data.claims
})
@@ -0,0 +1,31 @@
import { createServerClient } from '@supabase/ssr'
import { getCookies, setCookie, setResponseHeader } from '@tanstack/react-start/server'
export function createClient() {
return createServerClient(
process.env.VITE_SUPABASE_URL!,
process.env.VITE_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return Object.entries(getCookies()).map(
([name, value]) =>
({
name,
value,
}) as { name: string; value: string }
)
},
setAll(cookies, headers) {
cookies.forEach(({ name, value, options }) => {
setCookie(name, value, options)
})
Object.entries(headers).forEach(([name, value]) => {
setResponseHeader(name, value)
})
},
},
}
)
}
@@ -0,0 +1,17 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
import { fetchClaims } from '@/lib/supabase/fetch-claims-server-fn'
export const Route = createFileRoute('/_protected')({
beforeLoad: async () => {
const claims = await fetchClaims()
if (!claims) {
throw redirect({ to: '/login' })
}
return {
claims,
}
},
})