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 { AuthProvider } from "@better-auth-ui/heroui"
import { deleteUserPlugin } from "@better-auth-ui/heroui/plugins"
import { Toast } from "@heroui/react"
import { QueryClientProvider } from "@tanstack/react-query"
import { useRouter } from "next/navigation"
import type { ReactNode } from "react"

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

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()]}
      >
        {children}

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

The navigate prop integrates 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, Geist_Mono } from "next/font/google"
import { ThemeProvider } from "next-themes"
import type { ReactNode } from "react"

import "@/styles/app.css"

import { Header } from "@/components/header"
import { Providers } from "@/components/providers"

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

const geistMono = Geist_Mono({
  variable: "--font-geist-mono",
  subsets: ["latin"]
})

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>
      <body
        className={`${geistSans.variable} ${geistMono.variable} antialiased min-h-svh flex flex-col`}
      >
        <ThemeProvider
          defaultTheme="system"
          enableSystem
          disableTransitionOnChange
        >
          <Providers>
            <Header />

            {children}
          </Providers>
        </ThemeProvider>
      </body>
    </html>
  )
}

Create the Auth Page

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

app/auth/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Auth } from "@better-auth-ui/heroui"
import { notFound } from "next/navigation"

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 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. 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.

app/settings/[path]/page.tsx
import { viewPaths } from "@better-auth-ui/core"
import { Settings } from "@better-auth-ui/heroui"
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 { 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 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 (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 { Spinner } from "@heroui/react"
import Link from "next/link"

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, check out the next-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