BETTER-AUTH. UI
Integrations

TanStack Start

Integrate Solid/Zaidan components with TanStack Start

Prerequisites

Complete the Quick Start guide first.

Solid/Zaidan components require a Solid app with Better Auth UI runtime configuration. Registry entries such as solid/auth.json install copied components.

Your application still owns createAuthClient, QueryClient, router navigation, and the AuthProvider configuration.

Integration

Configure AuthProvider

Configure AuthProvider with TanStack Router navigation. Pass the Solid Query client from the route context.

components/providers.tsx
import { deleteUserPlugin } from "@better-auth-ui/core/plugins/delete-user"
import type { AuthLinkProps } from "@better-auth-ui/solid"
import type { QueryClient } from "@tanstack/solid-query"
import {
  Link as RouterLink,
  useNavigate,
  useParams
} from "@tanstack/solid-router"
import type { JSX } from "solid-js"
import { onCleanup, onMount, Show, splitProps } from "solid-js"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { emailOtpPlugin } from "@/lib/auth/email-otp-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 { twoFactorPlugin } from "@/lib/auth/two-factor-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

function AuthLink(props: AuthLinkProps) {
  const [local, linkProps] = splitProps(props, ["href"])

  return <RouterLink {...linkProps} to={local.href} />
}

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}
        Link={AuthLink}
        redirectTo="/settings/account"
        navigate={navigate}
        queryClient={props.queryClient}
        socialProviders={["github"]}
        plugins={[
          multiSessionPlugin(),
          apiKeyPlugin({
            organization: true,
            configurations: [
              { id: "default", label: "Personal", organization: false },
              { id: "organization", label: "Organization", organization: true }
            ]
          }),
          usernamePlugin({
            usernamePrefix: "@",
            localization: { usernamePlaceholder: "username" }
          }),
          magicLinkPlugin(),
          emailOtpPlugin({
            emailVerification: true,
            passwordReset: true,
            changeEmail: true
          }),
          twoFactorPlugin(),
          passkeyPlugin(),
          themePlugin(),
          deleteUserPlugin(),
          organizationPlugin({
            slugPrefix: "@",
            slug: organizationSlug(),
            teams: true
          })
        ]}
      >
        {() => (
          <>
            {resolveProviderChildren(props.children)}
            <Toaster />
          </>
        )}
      </AuthProvider>
    </Show>
  )
}

The navigate prop connects Better Auth UI to TanStack Router. Zaidan installs Solid components, but your application owns the provider configuration.

Update the Root Route

Wrap your application with Providers in the root route. Pass the route queryClient to the provider.

routes/__root.tsx
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. It renders shared interface elements such as the header.

Create the Auth Page

Install the composed authentication registry entry. Then create a dynamic page that selects the authentication view from the URL segment.

npx shadcn@latest add https://better-auth-ui.com/r/solid/auth.json
routes/auth/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, redirect } from "@tanstack/solid-router"

import { Auth } from "@/components/auth/auth"
import { emailOtpPlugin } from "@/lib/auth/email-otp-plugin"
import { magicLinkPlugin } from "@/lib/auth/magic-link-plugin"
import { organizationPlugin } from "@/lib/auth/organization-plugin"
import { twoFactorPlugin } from "@/lib/auth/two-factor-plugin"

const validAuthPathSegments = new Set([
  ...Object.values(viewPaths.auth),
  ...Object.values(magicLinkPlugin().viewPaths.auth),
  ...Object.values(organizationPlugin().viewPaths.auth),
  ...Object.values(emailOtpPlugin().viewPaths.auth),
  ...Object.values(twoFactorPlugin().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: redirect, sign-in, sign-up, sign-out, forgot-password, reset-password, reset-link-sent, and verify-email.

Create the Settings Page

If you installed the settings registry entry, create a dynamic settings route for the URL segment.

Validate the segment against viewPaths.settings. Return a 404 response for an unknown path.

npx shadcn@latest add https://better-auth-ui.com/r/solid/settings.json
routes/settings/$path.tsx
import { ensureSession, viewPaths } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/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 ensureSessionIso = createIsomorphicFn()
      .server(() =>
        ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
      )
      .client(() => ensureSession(queryClient, authClient))

    const session = await ensureSessionIso()

    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 segments account and security. Plugin registry entries can add segments through their local configuration.

Add the User Button (optional)

If the application shell needs a signed-in user menu, install the composed user button registry entry.

npx shadcn@latest add https://better-auth-ui.com/r/solid/user-button.json

The example application renders the button in the header. The copied Solid component supports SSR.

It renders a lightweight shell first. Then it creates session queries after the component mounts.

Protecting Routes

Better Auth UI provides separate protection patterns for server-rendered and prerendered routes.

Server-rendered routes (beforeLoad)

For an SSR route, read the session in beforeLoad. This redirects unauthenticated users before a component renders.

Use createIsomorphicFn to call ensureSessionServer on the server and ensureSession in the browser. The server helper calls auth.api directly.

Both helpers use authQueryKeys.session in the same TanStack Query cache. Child useSession calls can reuse the hydrated session.

routes/dashboard.tsx
import { ensureSession } from "@better-auth-ui/core"
import { ensureSessionServer } from "@better-auth-ui/core/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 ensureSessionIso = createIsomorphicFn()
      .server(() =>
        ensureSessionServer(queryClient, auth, { headers: getRequestHeaders() })
      )
      .client(() => ensureSession(queryClient, authClient))

    const session = await ensureSessionIso()

    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>
  )
}

Child routes and components can read the returned { session } through Route.useRouteContext().

ensureSessionServer also adds the session to the query cache during SSR. Downstream useSession calls can reuse the hydrated session.

Reactive protection and prerendered routes (useAuthenticate)

beforeLoad only runs when the route loads. It does not detect session changes while the page remains mounted.

These changes include token expiration, sign-out in another tab, and server-side session revocation. Prerendered and client-rendered routes also have no server check.

The useAuthenticate hook covers both cases. It subscribes to useSession and redirects the user when the session ends.

The hook preserves the current URL in the redirectTo query parameter.

Use it in two situations:

  1. Alongside beforeLoad for server-rendered routes, as a second layer that keeps the UI in sync after the initial load.
  2. On its own for prerendered or client-rendered routes that have no server-side session access.
routes/dashboard.tsx
import { useAuthenticate } from "@better-auth-ui/solid"
import { createFileRoute, A } from "@tanstack/solid-router"
import { Show } from "solid-js"

import { authClient } from "@/lib/auth-client"

export const Route = createFileRoute("/dashboard")({
  component: Dashboard
})

function Dashboard() {
  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>
  )
}

beforeLoad protects the initial render and hydrates the session. Then useAuthenticate reacts to later session changes.

Example Project

For a complete working example, see start-solid-zaidan-example in the repository.

Next Steps

Read about the shared Solid queries and mutations that power each Zaidan registry entry.

Last updated on

On this page