BETTER-AUTH. UI
Integrations

TanStack Start

Integrate Better Auth UI with TanStack Start

Prerequisites

Complete the Quick Start guide first.

Integration

Configure AuthProvider

Configure AuthProvider with TanStack Router's navigation.

components/providers.tsx
import { Link, useNavigate, useParams } from "@tanstack/react-router"
import { ThemeProvider, useTheme } from "next-themes"
import type { ReactNode } from "react"
import { apiKeyPlugin } from "@/lib/auth/api-key-plugin"
import { deleteUserPlugin } from "@/lib/auth/delete-user-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 { AuthProvider } from "./auth/auth-provider"
import { Toaster } from "./ui/sonner"

export function Providers({ children }: { children: ReactNode }) {
  const navigate = useNavigate()
  const { slug } = useParams({ strict: false })

  return (
    <ThemeProvider
      attribute="class"
      defaultTheme="system"
      enableSystem
      disableTransitionOnChange
    >
      <AuthProvider
        authClient={authClient}
        redirectTo="/settings/account"
        socialProviders={["github"]}
        emailAndPassword={{ requireEmailVerification: false }}
        navigate={navigate}
        plugins={[
          usernamePlugin({
            usernamePrefix: "@",
            localization: { usernamePlaceholder: "username" }
          }),
          magicLinkPlugin(),
          emailOtpPlugin({
            emailVerification: true,
            passwordReset: true,
            changeEmail: true
          }),
          twoFactorPlugin(),
          passkeyPlugin(),
          apiKeyPlugin({
            organization: true,
            configurations: [
              { id: "default", label: "Personal", organization: false },
              { id: "organization", label: "Organization", organization: true }
            ]
          }),
          themePlugin({ useTheme }),
          multiSessionPlugin(),
          deleteUserPlugin(),
          organizationPlugin({
            slugPrefix: "@",
            slug: slug ?? null,
            teams: true
          })
        ]}
        Link={({ href, ...props }) => <Link to={href} {...props} />}
      >
        {children}

        <Toaster />
      </AuthProvider>
    </ThemeProvider>
  )
}

The navigate and Link props connect Better Auth UI to TanStack Router. Pass the navigate function directly because it accepts { to, replace }.

Link requires a small wrapper. Better Auth UI passes the destination through href, but TanStack Router uses to.

Map href to to:

Link={({ href, ...props }) => <Link to={href} {...props} />}

Do not pass TanStack Router's Link directly as Link={Link}. It does not receive the required to value.

Without this value, the anchor resolves against the current route. The hover URL is incorrect, although the link still works.

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 shadcn/ui 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

Install the auth components. Then create a dynamic auth page that selects the authentication view from the path.

npx shadcn@latest add @better-auth-ui/auth
routes/auth/$path.tsx
import { viewPaths } from "@better-auth-ui/core"
import { createFileRoute, redirect } from "@tanstack/react-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),
  magicLinkPlugin().viewPaths.auth.magicLink,
  organizationPlugin().viewPaths.auth.acceptInvitation,
  emailOtpPlugin().viewPaths.auth.emailOtp,
  twoFactorPlugin().viewPaths.auth.twoFactor
])

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 className="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 component, 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 @better-auth-ui/settings
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/react-router"
import { createIsomorphicFn } from "@tanstack/react-start"
import { getRequestHeaders } from "@tanstack/react-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()

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

The viewPaths.settings object contains valid settings path segments: account and security.

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

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 then render without a loading state.

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 { authClient } from "@/lib/auth-client"
import { useAuthenticate } from "@better-auth-ui/react"
import { createFileRoute, Link } from "@tanstack/react-router"

import { Spinner } from "@/components/ui/spinner"

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

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

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-shadcn-example in the repository.

Next Steps

Read about the shared React hooks and query primitives that power each Better Auth UI component.

Last updated on

On this page