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 four-method RouterAdapter contract:
| Method | Returns | 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. |
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:
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_…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 /> }, ], },]);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.
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} /> );}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: 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.
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} /> </> );}# .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 { 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.
