Docent
Guides

Next.js

Mount Docent in a Next.js App Router app, with the client boundary, useNextAdapter, the environment variables, and where the component belongs.

Docent runs in the browser, so the mount is a client component. Put it in its own file, and render that file from the root layout so it wraps everything.

The client component

'use client'

import type { ReactNode } from 'react'
import { useRouter, usePathname, useSearchParams } from 'next/navigation'
import { Docent } from '@usedocent/react'
import { useNextAdapter } from '@usedocent/react/next'
import { PRESENCE_LOADERS } from '@usedocent/mascot/library/loaders'

export const Guide = ({ userId, children }: { userId: string; children: ReactNode }) => {
  const router = useNextAdapter(useRouter(), usePathname(), useSearchParams())

  return (
    <Docent
      appId={process.env.NEXT_PUBLIC_DOCENT_APP_ID!}
      publishableKey={process.env.NEXT_PUBLIC_DOCENT_PUBLISHABLE_KEY!}
      apiUrl="https://api.usedocent.com"
      user={{ id: userId }}
      router={router}
      loadPresence={(slug) => PRESENCE_LOADERS[slug]?.() ?? Promise.reject(new Error(slug))}
      loadAudio={(vendor) =>
        vendor === 'openai' ? import('@usedocent/sdk-audio-live') : import('@usedocent/sdk-audio')
      }
      voice="tap"
    >
      {children}
    </Docent>
  )
}

The adapter

useNextAdapter takes the results of the three hooks rather than importing next/navigation itself, so installing Docent does not put Next in the dependency graph of an app that is not using it. Its signature is:

useNextAdapter(
  router: { push: (href: string) => void },
  pathname: string,
  searchParams?: { toString: () => string } | null,
): RouterAdapter

It navigates with push, not replace, so a page the guide took the user to leaves a back button that returns them.

searchParams is optional and worth passing: Docent's route matching includes the query string, and usePathname alone reports /settings for /settings?tab=keys. Reading useSearchParams opts the component into client-side rendering under static export, which is why the adapter does not require it. If that is a problem, pass an object that reads the live location instead:

const search = useRef({ toString: () => globalThis.location?.search.replace(/^\?/, '') ?? '' })
const router = useNextAdapter(useRouter(), usePathname(), search.current)

The adapter calls toString() when it needs the path, not during render, so this stays fresher than a hook result captured a render ago.

The root layout

import { Guide } from './guide'

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

Wrapping matters. A <Guide /> rendered beside the page mounts a working guide and a useDocentTrack() that silently does nothing, because the context only reaches children, and a goal whose event never fires looks exactly like a goal nobody completed.

Environment variables

NEXT_PUBLIC_DOCENT_APP_ID=your-app-id
NEXT_PUBLIC_DOCENT_PUBLISHABLE_KEY=dk_pub_your_key
DOCENT_SECRET_KEY=dk_sec_your_key

Only the first two reach the browser, and both are meant to. The secret key uploads routes and knowledge, so it has no NEXT_PUBLIC_ prefix and stays on the server.

Pages the guide should skip

The mount re-keys its session on appId, publishableKey, apiUrl, user.id and userToken, so returning the children bare on the routes you want it off is the way to skip them. Hiding a mounted guide with CSS is not: it has already minted a session, and it will still load its audio chunk on a tap.

const NO_GUIDE = ['/sign-in', '/sign-up']
const allowed = (p: string) => !NO_GUIDE.some((x) => p === x || p.startsWith(`${x}/`))

What to do when the variables are missing

A fork or a fresh checkout without the environment file should get an app with no guide, not a crash:

if (!APP_ID || !KEY || !API) return <>{children}</>

On this page