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.
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.
"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.
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:
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.
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.
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:
- Alongside an async server component for server-rendered routes, as a second layer that keeps the UI in sync after the initial load.
- 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.
"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