Next.js
@navree/next: the backend-for-frontend as route handlers, auth() and client() in server components, the proxy that guards pages, abilities, real-time channels, operations and workspaces in React.
On this page
@navree/next puts a Next.js app (App Router) on a Navree API. The server side, @navree/next/server, mounts the backend-for-frontend as route handlers, gives server components the signed-in user and a typed client, and guards pages in the proxy. The browser side is hooks and components: list queries in the URL, a data table, operations, workspaces and form errors, with abilities in @navree/next/abilities and real time in @navree/next/realtime.
Setting up
Install the packages
@navree/next,@navree/clientand@navree/theme(see installing). For abilities add@casl/abilityand@casl/react; for real time,pusher-js.Generate the client's schema
Shell navree openapi:clientCreate the app's Navree
web/src/lib/navree.ts import { createNavree } from "@navree/next/server"; export const navree = createNavree();Settings come from the
NAVREE_*variables when the first request arrives (see configuration); options passed tocreateNavreewin over them.Mount the route handlers
web/src/app/api/[...path]/route.ts import { navree } from "@/lib/navree"; export const dynamic = "force-dynamic"; export const { GET, POST, PUT, PATCH, DELETE, HEAD } = navree.handlers;web/src/app/broadcasting/auth/route.ts import { navree } from "@/lib/navree"; export const dynamic = "force-dynamic"; export const { POST } = navree.handlers;One catch-all serves sign-in, two-factor, sign-out and refresh, and forwards every other
/api/*call with the bearer token. To reach a server-rendered tool the API serves under a prefix, add the same two lines atapp/admin/[...path]/route.ts.Guard the pages
web/src/proxy.ts import { navree } from "@/lib/navree"; export const proxy = navree.proxy({ loginPath: "/login", publicPaths: ["/", "/pricing", "/docs/*"] }); export const config = { matcher: ["/((?!_next/|favicon.ico).*)"] };Before Next.js 16 the file is
middleware.tsand the exportmiddleware:export const middleware = navree.proxy({ ... }).
The proxy
navree.proxy(options) runs before every page:
- A page request without a session goes to
loginPath?next=<path and query>(307). - An access token that has expired is refreshed first: the new cookies go to the browser, and the request continues with the new
Cookieheader, soauth()and server components rendering this request already see the new token. - An expired session (the refresh failed) clears the cookies and goes to sign-in.
- API routes (
/api/,/broadcasting/),/_next/, files with an extension and requests that aren't page navigations pass untouched. A page navigation is aGETorHEADthat acceptstext/html, or a React Server Components request.
| Option | Default | Meaning |
|---|---|---|
loginPath | /login | The sign-in page; always public. |
publicPaths | [] | Pages anyone may open: exact paths, or prefixes ending in /*. |
passThrough | ["/api/", "/broadcasting/", "/_next/", "/__nextjs"] | Path prefixes the proxy never touches. |
signedInRedirect | none | Where a signed-in browser that opens the sign-in page goes. |
Whether a session is still valid is the API's call: the proxy reads the token's expiry, it doesn't verify it. A route the API refuses answers 401, and the client's onUnauthorized hook sends the browser to sign-in.
Server components
navree.auth() returns the signed-in user, the access token and the workspace, or null. It is cached per request and never refreshes (a server component can't set cookies; the proxy already did):
import { redirect } from "next/navigation";
import { navree } from "@/lib/navree";
import type { User } from "@/lib/api/types";
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await navree.auth<User>();
if (!session) redirect("/login");
return <Shell user={session.user} workspace={session.workspace}>{children}</Shell>;
}navree.client<paths>() is the typed client in bearer mode, pointed at the API with the session's token and workspace:
import { unwrap } from "@navree/client";
import { navree } from "@/lib/navree";
import type { paths } from "@/lib/api/schema";
export default async function DealPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const api = await navree.client<paths>();
const deal = await unwrap(api.GET("/api/v1/deals/{id}", { params: { path: { id } } }));
return <DealDetail deal={deal} />;
}navree.bff() is the underlying BFF, for anything else (bff().api(path, {accessToken})).
In the browser
The browser's client is the typed client in cookie mode, with the workspace from its cookie and hooks for 401 and 403:
import { createNavreeClient } from "@navree/client";
import { readWorkspaceCookie } from "@navree/next";
import type { paths } from "./schema";
export const api = createNavreeClient<paths>({
workspace: () => readWorkspaceCookie(),
onUnauthorized: () => window.location.assign(`/login?next=${encodeURIComponent(location.pathname + location.search)}`),
});Sign-in posts to the BFF and gets only the user back (the API's document describes the API's answer, with the tokens, so read the BFF's with raw):
import { NavreeError } from "@navree/client";
const response = await api.raw("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
});
const body = await response.json().catch(() => null);
if (!response.ok) throw NavreeError.fromResponse(response, body);
if (body.two_factor_required) showCodeStep(); // then POST /api/auth/2fa/verify with { code } or { recovery_code }
else signedIn(body.user);Abilities
"use client";
import type { CaslRule } from "@navree/client/abilities";
import { AbilityProvider } from "@navree/next/abilities";
export function Providers({ rules, children }: { rules: CaslRule[] | undefined; children: React.ReactNode }) {
return <AbilityProvider rules={rules}>{children}</AbilityProvider>;
}import { Can, useAbility } from "@navree/next/abilities";
<Can I="update" this={deal}>
<EditButton />
</Can>;
const ability = useAbility();
const canMove = ability.can("move", deal);AbilityProvider builds one ability from the rules GET /api/me/abilities serves (load them with fetchAbilities({ fetch: api.raw }) from @navree/client/abilities, or in a server component). Everything is denied until the rules arrive; new rules (after sign-in or a workspace switch) update the ability in place, and every useAbility() and <Can> re-renders. Records are checked by their __type. The UI hides what the rules deny; the API still checks every request.
Real time
"use client";
import { RealtimeProvider } from "@navree/next/realtime";
import { api } from "@/lib/api/client";
const server = {
key: process.env.NEXT_PUBLIC_PUSHER_KEY!,
wsHost: process.env.NEXT_PUBLIC_WS_HOST!,
wsPort: Number(process.env.NEXT_PUBLIC_WS_PORT),
};
export function Live({ children }: { children: React.ReactNode }) {
return (
<RealtimeProvider server={server} options={{ authorize: api.raw }}>
{children}
</RealtimeProvider>
);
}import { useChannel, useRealtimeState } from "@navree/next/realtime";
useChannel("crm.deals", "deal.updated", () => queryClient.invalidateQueries({ queryKey: deals.all }));
useChannel(user ? `users.${user.id}` : null, "notification.created", (n: AppNotification) => toast(n.data.title));
const state = useRealtimeState(); // "connecting", "connected", "unavailable", "failed", "disconnected"RealtimeProvider opens one pusher-js connection after the first render and closes it on unmount; enabled={false} keeps it closed (signed out). useChannel listens while the component is mounted, keeps its subscription when the handler changes, and moves it when the channel does; null listens to nothing. A bare channel name is private. Real time is optional: when the socket can't connect, the app keeps working and queries still refetch. List the web origin in the API's realtime.allowed_origins. In tests, pass connect={() => createMemoryRealtime()} and play the server with emit.
Operations
import { useOperation } from "@navree/next";
function ImportProgress({ id }: { id: string | null }) {
const { operation, finished, error, cancel } = useOperation<{ imported: number }>(id, {
onFinished: () => queryClient.invalidateQueries({ queryKey: contacts.all }),
});
if (error) return <p>{errorMessage(error)}</p>;
return (
<div>
<progress value={operation?.progress?.percent ?? 0} max={100} />
{!finished && <button onClick={() => cancel()}>Cancel</button>}
</div>
);
}useOperation(id) follows the operation's event stream through the BFF (followOperation), re-renders on every change, and stops reading when the component unmounts or the id changes.
Workspaces
import { useWorkspace, WorkspaceProvider } from "@navree/next";
<WorkspaceProvider initial={session.workspace} onChange={() => { queryClient.clear(); router.refresh(); }}>
{children}
</WorkspaceProvider>;
const { workspace, setWorkspace } = useWorkspace();The current workspace lives in the navree_workspace cookie (not httpOnly: it isn't a secret). The browser's client sends it as X-Workspace, the BFF falls back to it, and auth() reads it, so server components and the browser agree on the workspace. readWorkspaceCookie() and writeWorkspaceCookie(slug) read and write it directly.
Form errors
import { applyFieldErrors } from "@navree/next";
import { errorMessage } from "@navree/client";
onError: (error) => applyFieldErrors(error, form.setError, ["email", "name"]) || toast.error(errorMessage(error));applyFieldErrors puts a 400's field errors on react-hook-form's fields (any function with setError's signature works) and the messages for fields the form doesn't know on root.server.
Configuration
| Variable | Read | Default | Meaning |
|---|---|---|---|
NAVREE_API_URL | server, first request | http://127.0.0.1:8080 | Where the BFF forwards. |
NAVREE_COOKIE_SECURE | server | on in production | 0 to serve a production build over plain http. |
APP_ORIGINS | server | none | Extra origins allowed to send non-GET requests. |
NAVREE_TRUSTED_PROXY_HOPS | server | 0 | Reverse proxies that append the client to X-Forwarded-For. |
NAVREE_WORKSPACE_HEADER | server | X-Workspace | The API's workspace.header. |
NEXT_PUBLIC_PUSHER_KEY, NEXT_PUBLIC_WS_HOST, NEXT_PUBLIC_WS_PORT | build | none | The API's real-time server, if you pass them to RealtimeProvider. |
Moving an existing app onto the package
The reference CRM front end, apps/crm-web, has these pieces as its own files. Moving it onto the packages replaces them one for one:
apps/crm-web today | With the packages |
|---|---|
src/lib/bff/bff.ts, http-errors.ts, server.ts | createNavree() in src/lib/navree.ts |
src/app/api/[...path]/route.ts and the four src/app/api/auth/*/route.ts | One catch-all exporting navree.handlers |
src/app/broadcasting/auth/route.ts | export const { POST } = navree.handlers |
src/lib/api/client.ts (createApiClient, unwrap, rawFetch) | createNavreeClient<paths>(), unwrap, api.raw |
src/lib/api/errors.ts (ApiError, errorMessage) | NavreeError (with code), errorMessage |
src/lib/api/form-errors.ts | applyFieldErrors from @navree/next |
src/lib/api/keys.ts | queryKeys("deals") per resource |
src/lib/casl/ability.ts | createAbility and AbilityProvider |
src/lib/realtime/pusher.ts, mock-bus.ts, realtime-bridge.tsx | RealtimeProvider, useChannel, createMemoryRealtime |
src/components/data-table.tsx, pagination-bar.tsx, src/hooks/use-url-state.ts | DataTable, Pagination, useListQuery (see data tables) |
The tokens in src/app/globals.css, src/app/fonts.ts | @navree/theme/theme.css and the fonts (see theming) |
The cookie names, the routes and the error shape are the same, so the move can go file by file with the app working after each step. Two things change for the app's code: errors are NavreeError instead of ApiError (status, errors and requestId keep their meaning; fieldErrors becomes errors), and channel listeners stop with the function listen returns instead of leave. The CRM-specific parts stay in the app: its types, its screens, its mock API and its query hooks.
Testing
The server side is plain functions: mock next/headers for auth(), build requests with browserRequest from @navree/bff/testing (wrap them in NextRequest for the proxy) and give createNavree a fakeNavree fetch:
import { browserRequest, fakeNavree, tokenPair } from "@navree/bff/testing";
import { createNavree } from "@navree/next/server";
import { NextRequest } from "next/server";
const navree = createNavree({ apiUrl: "http://api.test", fetch: fakeNavree({ "POST /api/auth/refresh": () => Response.json(tokenPair(2)) }).fetch });
const response = await navree.proxy()(new NextRequest(browserRequest("http://app.test", "/deals", { headers: { accept: "text/html" } })));
expect(response.status).toBe(307);The package's own suite (pnpm test in packages/js) runs the same way, and pnpm smoke builds a small app on the package with next build and checks the running server.