BETTER-AUTH. UI
Integrations

Next.js

Integrate Better Auth UI with Next.js

Prerequisites

Complete the Quick Start guide first.

Integration

Create the QueryClient

Create a shared QueryClient factory with the standard Next.js SSR pattern. Create one client per server request and one browser singleton.

lib/query-client.ts
import { environmentManager, QueryClient } from "@tanstack/react-query"

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 5000
      }
    }
  })
}

let browserQueryClient: QueryClient | undefined

export function getQueryClient() {
  if (environmentManager.isServer()) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

On the server, each call returns a new QueryClient. This design keeps the request cache separate for each user.

In the browser, the singleton preserves the React Query cache across navigation. It also gives HydrationBoundary a stable hydration target.

Configure AuthProvider

Configure AuthProvider with Next.js navigation. Then wrap it in QueryClientProvider so it uses the shared client.

components/providers.tsx
"use client"

import { QueryClientProvider } from "@tanstack/react-query"
import Link from "next/link"
import { useParams, useRouter } from "next/navigation"
import { 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 { 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 { getQueryClient } from "@/lib/query-client"
import { AuthProvider } from "./auth/auth-provider"
import { Toaster } from "./ui/sonner"

const normalizeParam = (param: string | string[] | undefined) =>
  (Array.isArray(param) ? param[0] : param)?.replace(/^@/, "") ?? null

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()
  const params = useParams()
  const queryClient = getQueryClient()
  const slug = normalizeParam(params.slug)

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider
        authClient={authClient}
        redirectTo="/settings/account"
        socialProviders={["google", "github"]}
        emailAndPassword={{ requireEmailVerification: false }}
        navigate={({ to, replace }) =>
          replace ? router.replace(to) : router.push(to)
        }
        plugins={[
          usernamePlugin({
            usernamePrefix: "@",
            localization: { usernamePlaceholder: "username" }
          }),
          magicLinkPlugin(),
          passkeyPlugin(),
          apiKeyPlugin({ organization: true }),
          themePlugin({ useTheme }),
          multiSessionPlugin(),
          deleteUserPlugin(),
          organizationPlugin({
            slugPrefix: "@",
            slug
          })
        ]}
        Link={Link}
      >
        {children}

        <Toaster />
      </AuthProvider>
    </QueryClientProvider>
  )
}

The navigate and Link props connect Better Auth UI to Next.js navigation. The navigate prop accepts { to, replace } options.

Update the Root Layout

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

app/layout.tsx
import type { Metadata } from "next"
import { Geist } from "next/font/google"
import type { ReactNode } from "react"

import "@/styles/app.css"

import { ThemeProvider } from "next-themes"
import { Header } from "@/components/header"
import { Providers } from "@/components/providers"
import { cn } from "@/lib/utils"

const geist = Geist({ subsets: ["latin"], variable: "--font-sans" })

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app"
}

export default function RootLayout({
  children
}: Readonly<{
  children: ReactNode
}>) {
  return (
    <html
      lang="en"
      suppressHydrationWarning
      className={cn("font-sans", geist.variable)}
    >
      <body className="antialiased min-h-svh flex flex-col">
        <ThemeProvider
          attribute="class"
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <Providers>
            <Header />

            {children}
          </Providers>
        </ThemeProvider>
      </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
app/auth/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { magicLinkPlugin } from "@better-auth-ui/core/plugins/magic-link"
import { notFound } from "next/navigation"

import { Auth } from "@/components/auth/auth"

const validAuthPaths = new Set([
  ...Object.values(viewPaths.auth),
  ...Object.values(magicLinkPlugin().viewPaths.auth ?? {})
])

export default async function AuthPage({
  params
}: {
  params: Promise<{
    path: string
  }>
}) {
  const { path } = await params

  if (!validAuthPaths.has(path)) {
    notFound()
  }

  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.

The async server component validates the path and session. It redirects unauthenticated users before it sends HTML.

The component also adds the session query to HydrationBoundary. As a result, child hooks skip their loading state during hydration.

npx shadcn@latest add @better-auth-ui/settings
app/settings/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { organizationPlugin } from "@better-auth-ui/core/plugins/organization"
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { headers } from "next/headers"
import { notFound, redirect } from "next/navigation"

import { Settings } from "@/components/auth/settings/settings"
import { auth } from "@/lib/auth"
import { getQueryClient } from "@/lib/query-client"

const validSettingsPaths = new Set([
  ...Object.values(viewPaths.settings),
  ...Object.values(organizationPlugin().viewPaths.settings ?? {})
])

export default async function SettingsPage({
  params
}: {
  params: Promise<{
    path: string
  }>
}) {
  const { path } = await params

  if (!validSettingsPaths.has(path)) {
    notFound()
  }

  const requestHeaders = await headers()
  const queryClient = getQueryClient()

  const session = await ensureSessionServer(queryClient, auth, {
    headers: requestHeaders
  })

  if (!session) {
    redirect(
      `/auth/sign-in?redirectTo=${encodeURIComponent(`/settings/${path}`)}`
    )
  }

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

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 (async server component)

For an SSR route, read the session in an async server component. This redirects unauthenticated users before the server streams HTML.

Call ensureSessionServer from @better-auth-ui/core/server with a separate QueryClient for each request. This helper calls auth.api directly.

Wrap the rendered children in HydrationBoundary. Then downstream useSession calls read authQueryKeys.session from the hydrated cache.

app/dashboard/page.tsx
import { ensureSessionServer } from "@better-auth-ui/core/server"
import { dehydrate, HydrationBoundary } from "@tanstack/react-query"
import { headers } from "next/headers"
import Link from "next/link"
import { redirect } from "next/navigation"

import { auth } from "@/lib/auth"
import { getQueryClient } from "@/lib/query-client"

export default async function Dashboard() {
  const queryClient = getQueryClient()

  const session = await ensureSessionServer(queryClient, auth, {
    headers: await headers()
  })

  if (!session) {
    redirect("/auth/sign-in?redirectTo=/dashboard")
  }

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

        <Link href="/auth/sign-out">Sign Out</Link>
      </div>
    </HydrationBoundary>
  )
}

ensureSession adds the session to the query cache during SSR. HydrationBoundary sends this state to the browser.

As a result, child components that call useSession render without a loading state.

If a client component outside the protected route reads the session, prefetch the session in its server parent. Then wrap that subtree in HydrationBoundary.

This pattern commonly applies to a header or sidebar that contains UserButton. Without the prefetch, the component starts a new browser request.

See the example header for the complete pattern.

Reactive protection and prerendered routes (useAuthenticate)

Server-side session checks only occur when the route loads. They do 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 an async server component 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.
app/dashboard/page.tsx
"use client"

import { authClient } from "@/lib/auth-client"
import { useAuthenticate } from "@better-auth-ui/react"
import Link from "next/link"

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

export default 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 href="/auth/sign-out">Sign Out</Link>
    </div>
  )
}

The async server component protects the initial render and hydrates the session. Then useAuthenticate reacts to later session changes.

Example Project

For a complete working example, see next-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