BETTER-AUTH. UI
Integrations

TanStack Start

Integrate Better Auth UI with TanStack Start

Prerequisites

Make sure you've completed the Quick Start guide first.

Integration

Configure AuthProvider

Configure AuthProvider with TanStack Router's navigation.

components/providers.tsx
import { AuthProvider } from "@better-auth-ui/heroui"
import { themePlugin } from "@better-auth-ui/heroui/plugins"
import { Toast } from "@heroui/react"
import { useNavigate } from "@tanstack/react-router"
import { ThemeProvider, useTheme } from "next-themes"
import type { ReactNode } from "react"

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

export function Providers({ children }: { children: ReactNode }) {
  const navigate = useNavigate()

  return (
    <ThemeProvider defaultTheme="system" enableSystem disableTransitionOnChange>
      <AuthProvider
        authClient={authClient}
        redirectTo="/settings/account"
        socialProviders={["github"]}
        navigate={navigate}
        plugins={[themePlugin({ useTheme })]}
      >
        {children}

        <Toast.Provider />
      </AuthProvider>
    </ThemeProvider>
  )
}

The navigate prop integrates with TanStack Router's navigation system. The navigate prop accepts { to, replace } options to handle both push and replace navigation.

Update the Root Route

Wrap your application with the Providers component in your root route.

routes/__root.tsx
import { TanStackDevtools } from "@tanstack/react-devtools"
import type { QueryClient } from "@tanstack/react-query"
import {
  createRootRouteWithContext,
  HeadContent,
  Scripts
} from "@tanstack/react-router"
import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"
import type { ReactNode } from "react"
import { Header } from "@/components/header"
import { Providers } from "@/components/providers"
import appCss from "@/styles/app.css?url"

export const Route = createRootRouteWithContext<{
  queryClient: QueryClient
}>()({
  head: () => ({
    meta: [
      {
        charSet: "utf-8"
      },
      {
        name: "viewport",
        content: "width=device-width, initial-scale=1"
      },
      {
        title: "Start HeroUI Example"
      }
    ],
    links: [
      {
        rel: "stylesheet",
        href: appCss
      }
    ]
  }),
  shellComponent: RootDocument
})

function RootDocument({ children }: { children: ReactNode }) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <HeadContent />
      </head>

      <body className="antialiased min-h-svh flex flex-col">
        <Providers>
          <Header />

          {children}
        </Providers>

        <TanStackDevtools
          config={{
            position: "bottom-right"
          }}
          plugins={[
            {
              name: "TanStack Router",
              render: <TanStackRouterDevtoolsPanel />
            }
          ]}
        />

        <Scripts />
      </body>
    </html>
  )
}

Create the Auth Page

Create a dynamic auth page that renders the appropriate authentication view based on the path:

routes/auth/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Auth } from "@better-auth-ui/heroui"
import { magicLinkPlugin } from "@better-auth-ui/heroui/plugins"
import { createFileRoute, notFound } from "@tanstack/react-router"

/** Keep in sync with `magicLinkPlugin(...)` in `providers.tsx` if you customize `path`. */
const validAuthPathSegments = new Set([
  ...Object.values(viewPaths.auth),
  magicLinkPlugin().viewPaths.auth.magicLink
])

export const Route = createFileRoute("/auth/$path")({
  beforeLoad({ params: { path } }) {
    if (!validAuthPathSegments.has(path)) {
      throw notFound()
    }
  },
  component: AuthPage
})

function AuthPage() {
  const { path } = Route.useParams()

  return (
    <div className="flex justify-center my-auto p-4 md:p-6">
      <Auth path={path} />
    </div>
  )
}

The built-in viewPaths.auth object contains sign-in, sign-up, sign-out, forgot-password, and reset-password. If you register plugins that contribute their own auth paths, spread each plugin's viewPaths.auth into the validation set — see the per-plugin docs (e.g. Magic Link).

Create the Settings page

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.

routes/settings/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Settings } from "@better-auth-ui/heroui"
import { organizationPlugin } from "@better-auth-ui/heroui/plugins"
import { ensureSession as ensureSessionClient } from "@better-auth-ui/react"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/react/server"
import { createFileRoute, notFound, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-start/server"

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

/** Same pattern as magic-link: spread plugin `viewPaths.settings` into the allowed segment set. */
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()

  return (
    <div className="w-full max-w-3xl mx-auto p-4 md:p-6">
      <Settings path={path} />
    </div>
  )
}

The built-in viewPaths.settings object contains account and security. If a plugin contributes its own settings views, spread its viewPaths.settings the same way as the auth route above.

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.

routes/dashboard.tsx
import { ensureSession as ensureSessionClient } from "@better-auth-ui/react"
import { ensureSession as ensureSessionServer } from "@better-auth-ui/react/server"
import { createFileRoute, Link, redirect } from "@tanstack/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-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 className="flex flex-col items-center my-auto">
      <h1 className="text-2xl">Hello, {session.user.email}</h1>

      <Link to="/auth/$path" params={{ path: "sign-out" }}>
        Sign Out
      </Link>
    </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 render synchronously without a loading state.

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:

  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 statically prerendered or purely client-rendered routes (SSG output, SPA routes, marketing pages), where server-side session access isn't available.
routes/dashboard.tsx
import { useAuth, useAuthenticate } from "@better-auth-ui/react"
import { Spinner } from "@heroui/react"
import { createFileRoute, Link } from "@tanstack/react-router"

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

function Dashboard() {
  const { authClient } = useAuth()
  const { data: session } = useAuthenticate(authClient)

  if (!session) {
    return (
      <div className="flex justify-center my-auto">
        <Spinner color="current" />
      </div>
    )
  }

  return (
    <div className="flex flex-col items-center my-auto">
      <h1 className="text-2xl">Hello, {session.user.email}</h1>

      <Link to="/auth/$path" params={{ path: "sign-out" }}>
        Sign Out
      </Link>
    </div>
  )
}

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, check out the start-heroui-example in the repository.

Next Steps

Explore the shared React hooks and query primitives powering every Better Auth UI component.

Last updated on

On this page