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