BETTER-AUTH. UI
Integrations

Next.js

Integrate Better Auth UI with Next.js

Prerequisites

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

Integration

Set up the QueryClient

Create a shared QueryClient factory using the canonical Next.js SSR pattern — a fresh client per server request, a singleton in the browser.

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, every call returns a new QueryClient so request caches never bleed across users. On the client, the singleton keeps the React Query cache consistent across navigations so HydrationBoundary has a stable target to rehydrate into.

Configure AuthProvider

Configure AuthProvider with Next.js navigation and wrap it in QueryClientProvider so AuthProvider picks up your shared client instead of spinning up its internal fallback.

components/providers.tsx
"use client"

import { QueryClientProvider } from "@tanstack/react-query"
import Link from "next/link"
import { useRouter } from "next/navigation"
import type { ReactNode } from "react"
import { deleteUserPlugin } from "@/lib/auth/delete-user-plugin"
import { authClient } from "@/lib/auth-client"
import { getQueryClient } from "@/lib/query-client"
import { AuthProvider } from "./auth/auth-provider"
import { Toaster } from "./ui/sonner"

export function Providers({ children }: { children: ReactNode }) {
  const router = useRouter()
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <AuthProvider
        authClient={authClient}
        redirectTo="/settings/account"
        socialProviders={["google", "github"]}
        navigate={({ to, replace }) =>
          replace ? router.replace(to) : router.push(to)
        }
        plugins={[deleteUserPlugin()]}
        Link={Link}
      >
        {children}

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

The navigate and Link props integrate with Next.js's navigation system. The navigate prop accepts { to, replace } options to handle both push and replace navigation.

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 and create a dynamic auth page that renders the appropriate authentication view based on the path.

npx shadcn@latest add https://better-auth-ui.com/r/auth.json
app/auth/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { notFound } from "next/navigation"

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

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

  if (!Object.values(viewPaths.auth).includes(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: sign-in, sign-up, sign-out, forgot-password, and reset-password.

Create the Settings page

If you installed the settings component, create a dynamic settings route that renders the Settings component for the URL segment. This page is an async server component that validates the path, checks the session with ensureSession (redirecting unauthenticated users before any HTML is sent), and dehydrates the session query into the client via HydrationBoundary so child hooks skip their loading state on hydration.

npx shadcn@latest add https://better-auth-ui.com/r/settings.json
app/settings/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { ensureSession } from "@better-auth-ui/react/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"

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

  if (!Object.values(viewPaths.settings).includes(path)) {
    notFound()
  }

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

  const session = await ensureSession(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 supports two route protection patterns depending on whether your route is server-rendered or prerendered.

Server-rendered routes (async server component)

For SSR routes, check the session in an async server component so unauthenticated users are redirected before any HTML is streamed — no flash of unauthenticated UI. Call ensureSession from @better-auth-ui/react/server with a per-request QueryClient (direct auth.api call, no HTTP hop), and wrap the rendered children in HydrationBoundary so downstream useSession calls read from the hydrated cache under authQueryKeys.session on mount.

app/dashboard/page.tsx
import { ensureSession } from "@better-auth-ui/react/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 ensureSession(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>
  )
}

Because ensureSession populates the query cache during SSR and HydrationBoundary ships the dehydrated state to the client, child components calling useSession render synchronously without a loading state.

If you have client components outside of any protected route that also read the session — typically a header or sidebar with a UserButton — prefetch the session in that subtree's server parent and wrap it in its own HydrationBoundary. Otherwise those components trigger a fresh client-side fetch on mount. See the example header for the full pattern.

Reactive protection & prerendered routes (useAuthenticate)

Server-side checks only run when the route loads — they can't react to session changes 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 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 statically prerendered or purely client-rendered routes (SSG output, client-only views, marketing pages), where server-side session access isn't available.
app/dashboard/page.tsx
"use client"

import { useAuth, useAuthenticate } from "@better-auth-ui/react"
import Link from "next/link"

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

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

In combination, the async server component 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 next-shadcn-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