Framework adapters

Beta
Last updated  Oct 5, 2026

An 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

RouterDiscoveryNavigationAdapter
React Router 6.4+ and 7 (data router)Automatic, from router.routesrouter.navigate(path)@voqal/usher-react-router
Next.js App RouterYou list the routesrouter.push(path)@voqal/usher-next
Next.js Pages RouterYou list the routesrouter.push(path)@voqal/usher-next
Anything elseYou decideYou decideYour own RouterAdapter

Every adapter implements the same RouterAdapter contract: four methods and an optional label.

MemberTypeContract
discover()DestinationCandidate[]Routes the framework exposes: { destinationId, routePattern, labels? }.
navigate(target)Promise<void> | voidGo to target.path through your router. Throw or reject to report a failure.
onLocationChange(callback)() => voidCall 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)stringA 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.

src/usher.tsx
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}    />  );}
src/main.tsx
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
# .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.

src/session.ts
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,  );}
src/usher-ref.ts
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>();
src/usher.tsx
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}    />  );}
src/main.tsx
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>,);
src/sign-out.ts
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.

project layout
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_…
lib/usher/destinations.ts
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 });
components/usher/usher-layer.tsx
"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.

components/usher/usher-layer-client-only.tsx
"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 },);
app/(signed-in)/layout.tsx
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
# .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.

OptionTypeDescription
routesDestinationCandidate[]What discover() returns. Default [].
getLocation() => AppLocationThe current location. Default { pathname: "/", params: {} }, which is wrong for every other page, so always pass it.
subscribe(callback) => () => voidRoute-change subscription. Without it, a live session isn't told when the user moves.
Usher calls 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.

components/usher/usher-layer.tsx
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 })).

src/history-adapter.ts
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 a navigation_failed refusal 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.
© 2026 VoqalVoqal SDK & engine documentation