Framework adapters

Beta
Last updated  Sep 23, 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 four-method RouterAdapter contract:

MethodReturnsContract
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.

React Router

Use a data router (createBrowserRouter or createMemoryRouter) from React Router 6.4 or newer, including 7. The adapter 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. In that case Usher still reports navigated with the path it asked for, and your router shows the redirect target.

The canonical layout is the same one the quickstart uses. Imports flow one way, so nothing is circular:

project layout
src/  router.tsx                 # createBrowserRouter(...): imports your pages only  usher/destinations.ts      # the map: imports router.tsx  usher/usher-layer.tsx      # renders <Usher>: imports destinations.ts  main.tsx                   # renders <RouterProvider> and <UsherLayer>.env.local                   # VITE_VOQAL_KEY=pk_test_…
src/router.tsx
import { createBrowserRouter } from "react-router-dom";import { Billing, BookConsultation, CaseDetail, Cases, Home, Layout } from "./pages";export const router = createBrowserRouter([  {    path: "/",    element: <Layout />,    children: [      { index: true, element: <Home /> },      { path: "cases", element: <Cases />, handle: { usherLabel: "My cases" } },      { path: "cases/:caseId", element: <CaseDetail /> },      { path: "consultations/book", element: <BookConsultation />, handle: { usherLabel: "Book a consultation" } },      { path: "settings/billing", element: <Billing /> },    ],  },]);
src/usher/destinations.ts
import { buildDestinationMap } from "@voqal/usher-core";import { reactRouterAdapter } from "@voqal/usher-react-router";import { router } from "../router";export const adapter = reactRouterAdapter(router);// Every route in your router becomes a destination. No list to maintain.export const DESTINATIONS = buildDestinationMap({ discovered: adapter.discover() });

That discovers every route automatically. To give your top pages richer titles, descriptions, aliases, and roles, add the optional manifest shown in the quickstart.

src/usher/usher-layer.tsx
import { useMemo } from "react";import { Usher } from "@voqal/usher-react";import { adapter, DESTINATIONS } from "./destinations";export function UsherLayer({ role }: { role: string }) {  const context = useMemo(() => ({ role }), [role]);  return (    <Usher      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 { UsherLayer } from "./usher/usher-layer";createRoot(document.getElementById("root")!).render(  <StrictMode>    <RouterProvider router={router} />    <UsherLayer role="member" /> {/* the signed-in user's role */}  </StrictMode>,);
.env.local
# .env.local: not committed. Use a pk_test_ key locally and pk_live_ in production.VITE_VOQAL_KEY=pk_test_…

Label a route for discovery with handle: { usherLabel: "My cases" }. See Discovery and merging.

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} />    </>  );}
.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 {    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