Back to journal

Integrating Authdog with the Next.js App Router

Connect Authdog to Next.js without confusing client identity, callback exchange, and trusted server-side authorization.

Authdog Team

4 min read
Next.js browser and server layers connected through an Authdog identity boundary

Most auth integrations look finished the moment the app can render a user avatar in the corner of the screen. That's a nice milestone, but it only proves the browser got hold of some identity data. It says nothing about whether your server will actually reject a forged or expired credential before it does any protected work.

Authdog's @authdog/nextjs-app package connects hosted sign-in to the Next.js App Router, and the integration holds up best when you let each layer do exactly one job: the provider manages browser state, the callback middleware exchanges the login response, and backend validation is the thing that actually protects your data.

Install the package and set your publishable key

Install it with whichever package manager you're using:

bun add @authdog/nextjs-app

Then set the same Authdog publishable pk_... key for both the client and server helpers:

NEXT_PUBLIC_PK_AUTHDOG=pk_your_environment_key
PK_AUTHDOG=pk_your_environment_key

The publishable key just identifies your environment — it's not a secret, and it can't authorize any privileged management API calls on its own. Keep your actual secret credentials well away from NEXT_PUBLIC_ variables.

The current package supports Next.js 15 and 16, on React 18 or 19.

Mount the provider once, at the root

Wrap your app near the root so client components can share Authdog state:

// app/layout.tsx
import { AuthdogProvider } from "@authdog/nextjs-app/client"

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>
        <AuthdogProvider>{children}</AuthdogProvider>
      </body>
    </html>
  )
}

The provider will pick up a JWT-shaped ?token= value from the URL and strip it out once it's consumed. But keep in mind that "shape check" only confirms there are three dot-separated segments — it doesn't establish a signature, issuer, audience, or expiry. Parsing a token and validating a token are two very different things, and it's easy to conflate them.

Read identity in client components

Reach for useUser when you just need user state for presentation:

"use client"

import { useUser } from "@authdog/nextjs-app"

export function AccountSummary() {
  const { user, isLoading } = useUser()

  if (isLoading) return <p>Loading account…</p>
  if (!user) return <a href="/sign-in">Sign in</a>

  return <p>Signed in as {user.emails?.[0]?.value}</p>
}

useAuth tells you whether a browser token exists, and it's great for driving UI transitions, loading states, and navigation decisions. What it is not, under any circumstances, is a permission check for a Server Action, a Route Handler, a database query, or a billing operation. The browser only controls browser state — an attacker can hit your server endpoint directly without ever rendering your React tree.

Exchange the hosted callback

The server helper takes the token returned by hosted sign-in, checks it against Authdog's userinfo endpoint, and writes HttpOnly cookies:

// middleware.ts
import { useAuthMiddleware } from "@authdog/nextjs-app/server"

export default useAuthMiddleware(process.env.PK_AUTHDOG!)

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
}

Make sure the return URL from hosted sign-in actually matches your middleware, and test that callback against your production path and reverse proxy — not just against localhost, where a lot of subtle routing issues never show up.

One boundary worth spelling out: this middleware handles the callback exchange. That's it. It doesn't protect your routes, and it doesn't validate cookies on every later request — that job still belongs to you.

Validate every protected request

A protected Route Handler or Server Action needs to validate the credential using Authdog's backend SDK for your server framework, and only then enforce authorization. In that order:

export async function POST(request: Request) {
  const session = await validateAuthdogSession(request)
  if (!session) return new Response("Unauthorized", { status: 401 })

  const allowed = await canManageBilling(session)
  if (!allowed) return new Response("Forbidden", { status: 403 })

  return updateBillingSettings(session)
}

validateAuthdogSession, canManageBilling, and updateBillingSettings are stand-ins for your own application boundaries here, not exports from @authdog/nextjs-app. Build them against the current backend SDK guidance and your authorization model.

It helps to keep three outcomes distinct in your head, and in your code:

  • 401 Unauthorized: there's no valid authenticated session at all.
  • 403 Forbidden: the identity is valid, but lacks the access it's asking for.
  • Success: both the identity and the authorization check passed.

And never, ever accept a user or organization identifier straight from the browser as a substitute for what's actually in the validated session claims.

Log out on both surfaces, not just one

Authdog's client state and your server cookies have separate cleanup paths. clearAuthdogSession() clears browser local storage; logoutHandler clears the server cookies. A complete logout calls both — clear only the client state and the server session lingers; clear only the cookie and the client UI goes stale while pretending otherwise.

It's also worth deciding up front what "log out" actually means for your product:

  • just this application session,
  • every session on this device,
  • or the identity provider session too.

Whatever you pick, describe it accurately in the UI so people know what's still signed in afterward.

Test the trust boundaries, not just the happy path

Before shipping, it's worth walking through more than a successful sign-in:

  1. The callback reaches middleware and the token disappears from the URL.
  2. Missing, malformed, expired, and revoked credentials all fail server validation.
  3. A direct request to a protected route fails without ever rendering the client app.
  4. A valid user without the right permission gets a 403.
  5. Switching organizations actually changes the server-side data scope.
  6. Logging out clears both browser state and HttpOnly cookies.
  7. Return URLs work behind your real deployed domain and proxy, not just locally.

The rule underneath all of this

The client provider makes identity usable. The callback middleware makes hosted sign-in convenient. Backend validation and authorization are what actually make the application secure. Keep those responsibilities separate, and you avoid the single most dangerous mistake in SDK integrations: mistaking a signed-in-looking UI for proof that a protected server request can be trusted.

From here, it's worth reading the Next.js framework guide, backend validation, and authorization concepts.