Framework adapters
BetaAn adapter connects Usher to your router, so every navigation runs through your app’s own routing, with its state, its guards, and its redirects intact. The shipped adapters never set location.href.
What an adapter does
| Router | Discovery | Navigation | Adapter |
|---|---|---|---|
| React Router 6.4+ and 7 (data router) | Automatic, from router.routes | router.navigate(path) | @voqal/usher-react-router |
| Next.js App Router | You list the routes | router.push(path) | @voqal/usher-next |
| Next.js Pages Router | You list the routes | router.push(path) | @voqal/usher-next |
| Anything else | You decide | You decide | Your own RouterAdapter |
Every adapter implements the same RouterAdapter contract: four methods and an optional label.
| Member | Type | Contract |
|---|---|---|
discover() | DestinationCandidate[] | Routes the framework exposes: { destinationId, routePattern, labels? }. |
navigate(target) | Promise<void> | void | Go to target.path through your router. Throw or reject to report a failure. |
onLocationChange(callback) | () => void | Call callback with the new AppLocation on every route change. Return an unsubscribe. |
currentLocation() | AppLocation | { pathname, params?, search? } right now. Params feed dynamic-param resolution. |
kind (optional) | string | A label for your adapter, such as "react-router", "next", or your own name. It appears in support diagnostics, so set it on a custom adapter. |
React Router
One file, with automatic discovery. Your router file stays as it is.
import { buildDestinationMap } from "@voqal/usher-core";import { Usher } from "@voqal/usher-react";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router"; // your existing createBrowserRouter(...)const adapter = reactRouterAdapter(router);const destinations = buildDestinationMap({ discovered: adapter.discover() });export function UsherAssistant() { return ( <Usher voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} /> );}import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { RouterProvider } from "react-router-dom";import { router } from "./router";import { UsherAssistant } from "./usher";createRoot(document.getElementById("root")!).render( <StrictMode> <RouterProvider router={router} /> <UsherAssistant /> </StrictMode>,);# .env.local (not committed). Restart the dev server after editing it.VITE_VOQAL_KEY=pk_test_…To give your top pages richer titles, descriptions, aliases, and roles, add the optional manifest. To label a route without one, set handle: { usherLabel: "My cases" } on it.
Signed-in apps: show Usher only to signed-in users
Render <UsherAssistant /> beside <RouterProvider> only when your session says the user is signed in, and end Usher’s session before you clear yours. Four small files keep imports one-way: usher.tsx imports the router, and your pages reach Usher through usher-ref.ts, never through usher.tsx, so a sign-out button inside a route creates no cycle. Use your own session store if you have one; this one is a minimal example.
import { useSyncExternalStore } from "react";type Session = { userId: string; role: string } | null;let session: Session = null;const listeners = new Set<() => void>();export function setSession(next: Session): void { session = next; listeners.forEach((notify) => notify());}export function useSession(): Session { return useSyncExternalStore( (notify) => { listeners.add(notify); return () => listeners.delete(notify); }, () => session, );}import { createRef } from "react";import type { UsherHandle } from "@voqal/usher-react";// One shared handle, so any part of the app can reach Usher without importing usher.tsx.export const usherRef = createRef<UsherHandle>();import { useMemo } from "react";import { buildDestinationMap } from "@voqal/usher-core";import { Usher } from "@voqal/usher-react";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "./router";import { usherRef } from "./usher-ref";const adapter = reactRouterAdapter(router);const destinations = buildDestinationMap({ discovered: adapter.discover() });export function UsherAssistant({ role }: { role: string }) { const context = useMemo(() => ({ role }), [role]); return ( <Usher ref={usherRef} voqalKey={import.meta.env.VITE_VOQAL_KEY} router={adapter} destinations={destinations} context={context} /> );}import { StrictMode } from "react";import { createRoot } from "react-dom/client";import { RouterProvider } from "react-router-dom";import { router } from "./router";import { useSession } from "./session";import { UsherAssistant } from "./usher";function App() { const session = useSession(); return ( <> <RouterProvider router={router} /> {session ? <UsherAssistant role={session.role} /> : null} </> );}createRoot(document.getElementById("root")!).render( <StrictMode> <App /> </StrictMode>,);import { setSession } from "./session";import { usherRef } from "./usher-ref";export async function signOut(): Promise<void> { // End the live session and clear the conversation first, while <Usher> is still mounted. await usherRef.current?.runtime().logout(); await fetch("/api/logout", { method: "POST" }); // your own sign-out setSession(null); // unmounts <Usher>}logout() ends the live session, closes and clears the panel, and drops the stored conversation and context. Calling it before you clear your session means the next user never sees the last one’s conversation.
How the adapter works
It needs a data router (createBrowserRouter or createMemoryRouter) from React Router 6.4 or newer, including 7. It walks router.routes for discovery, calls router.navigate(), subscribes with router.subscribe(), and reads params from the deepest match. The declarative <BrowserRouter> has no router object to pass, so it needs a custom adapter. A loader that redirects doesn’t make router.navigate() fail: Usher still reports navigated with the path it asked for, and your router shows the redirect target.
Next.js App Router
There’s no automatic route discovery for Next.js: the App Router doesn’t expose its route tree in the browser. Instead you pass your route patterns, which does the same job, and can then add the optional manifest on top. Write a folder like app/cases/[caseId] as the pattern /cases/:caseId. The adapter takes the object useRouter() returns and imports nothing from next itself.
The layout: the map lives in lib/usher/destinations.ts and imports nothing from your app, a client component renders <Usher>, and your signed-in layout (a server component) passes the user’s role through a client-only wrapper.
lib/usher/destinations.ts # ROUTES + manifest + map: imports nothing from your appcomponents/usher/usher-layer.tsx # "use client": the adapter and <Usher>components/usher/usher-layer-client-only.tsx # "use client": loads the layer with ssr: falseapp/(signed-in)/layout.tsx # server component: renders the layer with the user's role.env.local # NEXT_PUBLIC_VOQAL_KEY=pk_test_…import { buildDestinationMap, type DestinationCandidate, type DestinationManifestEntry } from "@voqal/usher-core";// The App Router has no route tree at runtime, so Usher can't discover routes on its own.// List your route patterns here; this is the Next.js equivalent of discovery.// A folder like app/cases/[caseId] is the pattern /cases/:caseId.export const ROUTES: DestinationCandidate[] = [ { destinationId: "cases", routePattern: "/cases" }, { destinationId: "cases.detail", routePattern: "/cases/:caseId" }, { destinationId: "consultations.book", routePattern: "/consultations/book" }, { destinationId: "settings.billing", routePattern: "/settings/billing" },];// Optional: richer meaning for the pages users ask for by name. It wins over the route list.const MANIFEST: DestinationManifestEntry[] = [ { destinationId: "cases", routePattern: "/cases", title: "My cases", semanticDescription: "Every legal matter the user has open or closed.", aliases: ["my cases", "case list", "my matters"], }, // ...one entry per page users ask for by name (see Destinations)];export const DESTINATIONS = buildDestinationMap({ discovered: ROUTES, manifest: MANIFEST });"use client";import { useEffect, useMemo, useRef } from "react";import { useParams, usePathname, useRouter } from "next/navigation";import type { AppLocation } from "@voqal/usher-core";import { Usher } from "@voqal/usher-react";import { nextAdapter } from "@voqal/usher-next";import { DESTINATIONS, ROUTES } from "@/lib/usher/destinations";export interface UsherLayerProps { role: string;}type LocationListener = (location: AppLocation) => void;export function UsherLayer({ role }: UsherLayerProps) { const router = useRouter(); const pathname = usePathname(); const params = useParams<Record<string, string>>(); const location = useRef<AppLocation>({ pathname, params }); const listeners = useRef(new Set<LocationListener>()); // Keep the adapter's view of the URL current, and tell Usher when it changes. useEffect(() => { location.current = { pathname, params }; listeners.current.forEach((notify) => notify(location.current)); }, [pathname, params]); const adapter = useMemo( () => nextAdapter(router, { routes: ROUTES, getLocation: () => location.current, subscribe: (notify) => { listeners.current.add(notify); return () => { listeners.current.delete(notify); }; }, }), [router], ); const context = useMemo(() => ({ role }), [role]); return ( <Usher voqalKey={process.env.NEXT_PUBLIC_VOQAL_KEY} router={adapter} destinations={DESTINATIONS} context={context} /> );}Render it in the browser only
Usher is browser-only: it uses the microphone, Web Audio, and a module-level session. Load it with ssr: false. Typing dynamic with the layer’s props lets the server layout pass role (or anything else) straight through.
"use client";import dynamic from "next/dynamic";import type { UsherLayerProps } from "./usher-layer";// Usher is browser-only: never render it on the server. Props pass straight through.export const UsherLayerClientOnly = dynamic<UsherLayerProps>( () => import("./usher-layer").then((module) => module.UsherLayer), { ssr: false },);import type { ReactNode } from "react";import { UsherLayerClientOnly } from "@/components/usher/usher-layer-client-only";import { getSessionUser } from "@/lib/auth"; // your authexport default async function SignedInLayout({ children }: { children: ReactNode }) { const user = await getSessionUser(); return ( <> {children} <UsherLayerClientOnly role={user.role} /> </> );}The signed-in layout is what limits Usher to signed-in users: pages outside that route group never render it. To sign out cleanly, pass a shared ref to <Usher ref={usherRef}> in the layer and call await usherRef.current?.runtime().logout() before you clear the session, as in the React Router example. Keep the ref module free of other imports and mark it "use client".
# .env.local: not committed. Use a pk_test_ key locally and pk_live_ in production.NEXT_PUBLIC_VOQAL_KEY=pk_test_…NEXT_PUBLIC_ variables are inlined at build time, so set NEXT_PUBLIC_VOQAL_KEY in each deployment environment before it builds, and restart next dev after creating or changing .env.local. A missing key means Usher quietly runs in local text-only mode. With Vite, restart the dev server for the same reason.
| Option | Type | Description |
|---|---|---|
routes | DestinationCandidate[] | What discover() returns. Default []. |
getLocation | () => AppLocation | The current location. Default { pathname: "/", params: {} }, which is wrong for every other page, so always pass it. |
subscribe | (callback) => () => void | Route-change subscription. Without it, a live session isn't told when the user moves. |
getLocation whenever it needs the current page, long after the render that built the adapter. Read the location from a ref, as the example does. An adapter that closes over one render’s pathname reports that page forever.Next.js Pages Router
The same adapter takes the router from next/router directly. Add a subscribe on router.events (routeChangeComplete) if you want live sessions to follow route changes, and pass params from router.query to use the current URL for dynamic params.
import { useMemo } from "react";import { useRouter } from "next/router";import { Usher } from "@voqal/usher-react";import { nextAdapter } from "@voqal/usher-next";import { DESTINATIONS, ROUTES } from "@/lib/usher/destinations";export function UsherLayer() { const router = useRouter(); const adapter = useMemo( () => nextAdapter(router, { routes: ROUTES, getLocation: () => ({ pathname: window.location.pathname }), }), [router], ); return <Usher voqalKey={process.env.NEXT_PUBLIC_VOQAL_KEY} router={adapter} destinations={DESTINATIONS} />;}Custom router
Any router works if you implement the four methods. Pair a router that has no route tree with a manifest-only map (buildDestinationMap({ manifest })).
import type { RouterAdapter } from "@voqal/usher-core";// A minimal adapter for an app that routes on the History API.export function historyAdapter(): RouterAdapter { const read = () => ({ pathname: window.location.pathname, search: window.location.search }); return { kind: "history-api", // shows in support diagnostics discover: () => [], // no route tree: use a manifest-only map navigate: (target) => { window.history.pushState(null, "", target.path); // target.path is already validated window.dispatchEvent(new PopStateEvent("popstate")); }, onLocationChange: (callback) => { const onPop = () => callback(read()); window.addEventListener("popstate", onPop); return () => window.removeEventListener("popstate", onPop); }, currentLocation: read, };}- navigate receives a path that already passed the validation chain: listed, in role, params resolved, relative. Don’t rebuild URLs from it.
- If your router refuses (a guard, a redirect, a thrown error), throw or reject from
navigate. Usher turns that into anavigation_failedrefusal instead of claiming it moved. - Return real params from
currentLocation()where you can. That lets “open the documents for this case” reuse the id already in the URL.
