TanStack Start
Integrate Solid/Zaidan components with TanStack Start
Prerequisites
Make sure you've completed the Quick Start guide first.
Solid/Zaidan components assume a Solid app that already has Better Auth UI runtime wiring. The composed registry entries, such as solid/auth.json, install copied components. Your app still owns createAuthClient, QueryClient, router navigation, and AuthProvider configuration.
Integration
Configure AuthProvider
Configure AuthProvider with TanStack Router's navigation and the Solid Query client from the route context.
import { deleteUserPlugin } from "@better-auth-ui/core/plugins"
import type { QueryClient } from "@tanstack/solid-query"
import { useNavigate, useParams } from "@tanstack/solid-router"
import type { JSX } from "solid-js"
import { onCleanup, onMount, Show } from "solid-js"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { magicLinkPlugin } from "@/lib/auth/magic-link-plugin"
import { multiSessionPlugin } from "@/lib/auth/multi-session-plugin"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { passkeyPlugin } from "@/lib/auth/passkey-plugin"
import { themePlugin } from "@/lib/auth/theme-plugin"
import { usernamePlugin } from "@/lib/auth/username-plugin"
import { authClient } from "@/lib/auth-client"
import { syncDocumentThemePreference } from "@/lib/theme"
import { AuthProvider } from "./auth/auth-provider"
import { Toaster } from "./ui/sonner"
export type ProvidersProps = {
children?: JSX.Element | (() => JSX.Element)
queryClient?: QueryClient
}
const resolveProviderChildren = (children: ProvidersProps["children"]) =>
typeof children === "function" ? children() : children
export function Providers(props: ProvidersProps) {
const navigate = useNavigate()
const params = useParams({ strict: false })
const organizationSlug = () => {
const slug = params().slug
if (typeof slug === "string" && slug.length > 0) return slug
return null
}
onMount(() => {
const cleanup = syncDocumentThemePreference()
onCleanup(cleanup)
})
return (
<Show keyed when={organizationSlug() ?? "personal"}>
<AuthProvider
authClient={authClient}
redirectTo="/settings/account"
navigate={navigate}
queryClient={props.queryClient}
socialProviders={["github"]}
plugins={[
multiSessionPlugin(),
apiKeyPlugin({ organization: true }),
usernamePlugin(),
magicLinkPlugin(),
passkeyPlugin(),
themePlugin(),
deleteUserPlugin(),
organizationPlugin({ slug: organizationSlug() })
]}
>
{() => (
<>
{resolveProviderChildren(props.children)}
<Toaster />
</>
)}
</AuthProvider>
</Show>
)
}The navigate prop integrates with TanStack Router's navigation system. Zaidan installs Solid component files, but the provider boundary remains app-owned runtime wiring.
Update the Root Route
Wrap your application with the Providers component in the root route and pass the route queryClient into the provider.
import type { QueryClient } from "@tanstack/solid-query"
import {
createRootRouteWithContext,
HeadContent,
Outlet,
Scripts
} from "@tanstack/solid-router"
import type { JSX } from "solid-js"
import { HydrationScript } from "solid-js/web"
import { Header } from "@/components/header"
import { Providers } from "@/components/providers"
import { themeScript } from "@/lib/theme"
import "../styles/globals.css"
export const Route = createRootRouteWithContext<{
queryClient: QueryClient
}>()({
component: RootComponent,
head: () => ({
meta: [
{ charset: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Start Solid Zaidan Example" }
]
}),
shellComponent: RootDocument
})
function RootComponent() {
return <Outlet />
}
function RootDocument(props: { children: JSX.Element }) {
const routeContext = Route.useRouteContext()
return (
<html lang="en">
<head>
<script>{themeScript}</script>
<HydrationScript />
</head>
<body class="antialiased min-h-svh flex flex-col bg-background text-foreground">
<HeadContent />
<Providers queryClient={routeContext().queryClient}>
{() => (
<>
<Header />
<main class="grow flex flex-col">{props.children}</main>
</>
)}
</Providers>
<Scripts />
</body>
</html>
)
}The root route also imports the global Tailwind v4 stylesheet and renders shared shell UI such as the header.
Create the Auth Page
Install the composed auth registry entry and create a dynamic auth page that renders the right authentication view from the URL segment.
npx shadcn@latest add https://better-auth-ui.com/r/solid/auth.jsonimport { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, redirect } from "@tanstack/solid-router"
import { Auth } from "@/components/auth/auth"
import { magicLinkPlugin } from "@/lib/auth/magic-link-plugin"
const validAuthPathSegments = new Set([
...Object.values(viewPaths.auth),
...Object.values(magicLinkPlugin().viewPaths.auth)
])
export const Route = createFileRoute("/auth/$path")({
beforeLoad({ params: { path } }) {
if (!validAuthPathSegments.has(path)) {
throw redirect({ to: "/" })
}
},
component: AuthPage
})
function AuthPage() {
const { path } = Route.useParams()()
return (
<div class="flex justify-center my-auto p-4 md:p-6">
<Auth path={path} />
</div>
)
}The viewPaths.auth object contains the built-in auth paths: sign-in, sign-up, sign-out, forgot-password, and reset-password.
Create the Settings Page
If you installed the settings registry entry, create a dynamic settings route that renders the Settings component for the URL segment. Validate the segment against viewPaths.settings so unknown paths return a 404.
npx shadcn@latest add https://better-auth-ui.com/r/solid/settings.jsonimport { viewPaths } from "@better-auth-ui/core"
import { ensureSession as ensureSessionClient } from "@better-auth-ui/solid"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/solid/server"
import { createFileRoute, notFound, redirect } from "@tanstack/solid-router"
import { createIsomorphicFn } from "@tanstack/solid-start"
import { getRequestHeaders } from "@tanstack/solid-start/server"
import { Settings } from "@/components/auth/settings/settings"
import { auth } from "@/lib/auth"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { authClient } from "@/lib/auth-client"
const validSettingsPaths = [
...Object.values(viewPaths.settings),
...Object.values(organizationPlugin().viewPaths.settings ?? {})
]
export const Route = createFileRoute("/settings/$path")({
async beforeLoad({ params: { path }, context: { queryClient }, location }) {
if (!validSettingsPaths.includes(path)) {
throw notFound()
}
const ensureSession = createIsomorphicFn()
.server(() =>
ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
)
.client(() => ensureSessionClient(queryClient, authClient))
const session = await ensureSession()
if (!session) {
throw redirect({
to: "/auth/$path",
params: { path: "sign-in" },
search: { redirectTo: location.href }
})
}
return { session }
},
component: SettingsPage
})
function SettingsPage() {
const path = () => Route.useParams()().path
return (
<div class="mx-auto w-full max-w-3xl p-4 md:p-6">
<Settings path={path()} />
</div>
)
}The viewPaths.settings object contains the base settings path segments: account and security. Plugin registry entries can add more settings paths, such as organizations, through their local plugin configuration.
Add the User Button (optional)
Install the composed user button registry entry when your shell needs the signed-in user menu.
npx shadcn@latest add https://better-auth-ui.com/r/solid/user-button.jsonThe example app renders this from the header. The copied Solid component is SSR-safe: it renders a lightweight shell first and creates session queries after mount.
Protecting Routes
Better Auth UI supports two route protection patterns depending on whether your route is server-rendered or prerendered.
Server-rendered routes (beforeLoad)
For SSR routes, check the session in beforeLoad so unauthenticated users are redirected before any component renders — no flash of unauthenticated UI. Use createIsomorphicFn to run ensureSessionServer on the server (direct auth.api call, no HTTP hop) and ensureSessionClient on the client (goes through authClient). Both share the same TanStack Query cache under authQueryKeys.session, so the session is hydrated from SSR and reused by any child useSession call.
import { ensureSession as ensureSessionClient } from "@better-auth-ui/solid"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/solid/server"
import { createFileRoute, A, redirect } from "@tanstack/solid-router"
import { createIsomorphicFn } from "@tanstack/solid-start"
import { getRequestHeaders } from "@tanstack/solid-start/server"
import { auth } from "@/lib/auth"
import { authClient } from "@/lib/auth-client"
export const Route = createFileRoute("/dashboard")({
async beforeLoad({ context: { queryClient }, location }) {
const ensureSession = createIsomorphicFn()
.server(() =>
ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
)
.client(() => ensureSessionClient(queryClient, authClient))
const session = await ensureSession()
if (!session) {
throw redirect({
to: "/auth/$path",
params: { path: "sign-in" },
search: { redirectTo: location.href }
})
}
return { session }
},
component: Dashboard
})
function Dashboard() {
const { session } = Route.useRouteContext()()
return (
<div class="flex flex-col items-center my-auto">
<h1 class="text-2xl">Hello, {session.user.email}</h1>
<A to="/auth/$path" params={{ path: "sign-out" }}>
Sign Out
</A>
</div>
)
}The returned { session } is available to child routes and components via Route.useRouteContext(). Because ensureSessionServer populates the query cache during SSR, downstream useSession calls can reuse the hydrated session.
Reactive protection & prerendered routes (useAuthenticate)
beforeLoad only runs when the route loads — it can't react to session changes that happen while the page is mounted (token expiry, sign-out in another tab via multiSessionPlugin, a revoked session from the server, etc.). And for statically prerendered or purely client-rendered routes there's no server to call in the first place. The useAuthenticate hook covers both: it subscribes to useSession and redirects unauthenticated users to the sign-in page the moment the session goes away, preserving the current URL as a redirectTo query param.
Use it in two situations:
- Alongside
beforeLoadfor server-rendered routes, as a second layer that keeps the UI in sync after the initial load. - On its own for statically prerendered or purely client-rendered routes (SSG output, SPA routes, marketing pages), where server-side session access isn't available.
import { useAuth, useAuthenticate } from "@better-auth-ui/solid"
import { createFileRoute, A } from "@tanstack/solid-router"
import { Show } from "solid-js"
export const Route = createFileRoute("/dashboard")({
component: Dashboard
})
function Dashboard() {
const { authClient } = useAuth()
const session = useAuthenticate(authClient)
return (
<Show
when={session.data}
fallback={
<div class="flex justify-center my-auto">
<div class="size-6 animate-spin rounded-full border-2 border-muted border-t-foreground" />
</div>
}
>
{(currentSession) => (
<div class="flex flex-col items-center my-auto">
<h1 class="text-2xl">Hello, {currentSession().user.email}</h1>
<A to="/auth/$path" params={{ path: "sign-out" }}>
Sign Out
</A>
</div>
)}
</Show>
)
}In combination, beforeLoad handles the initial render (including SSR hydration with no auth flash) and useAuthenticate handles everything after — giving you both first-paint protection and live reactivity to session changes.
Example Project
For a complete working example, see start-solid-zaidan-example in the repository.
Next Steps
Explore the shared Solid queries and mutations powering every Zaidan registry entry.
Last updated on