navree
Documentation

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

  1. Install the packages

    @navree/next, @navree/client and @navree/theme (see installing). For abilities add @casl/ability and @casl/react; for real time, pusher-js.

  2. Generate the client's schema

    Shell
  3. Create the app's Navree

    web/src/lib/navree.ts

    Settings come from the NAVREE_* variables when the first request arrives (see configuration); options passed to createNavree win over them.

  4. Mount the route handlers

    web/src/app/api/[...path]/route.ts

    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 at app/admin/[...path]/route.ts.

  5. Guard the pages

    web/src/proxy.ts

    Before Next.js 16 the file is middleware.ts and the export middleware: 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 Cookie header, so auth() 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 a GET or HEAD that accepts text/html, or a React Server Components request.
OptionDefaultMeaning
loginPath/loginThe 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.
signedInRedirectnoneWhere 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):

web/src/app/(app)/layout.tsx

navree.client<paths>() is the typed client in bearer mode, pointed at the API with the session's token and workspace:

web/src/app/(app)/deals/[id]/page.tsx

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:

web/src/lib/api/client.ts

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):

TypeScript

Abilities

web/src/app/providers.tsx
TSX

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

web/src/app/providers.tsx
TSX

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

TSX

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

TSX

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

TypeScript

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

VariableReadDefaultMeaning
NAVREE_API_URLserver, first requesthttp://127.0.0.1:8080Where the BFF forwards.
NAVREE_COOKIE_SECUREserveron in production0 to serve a production build over plain http.
APP_ORIGINSservernoneExtra origins allowed to send non-GET requests.
NAVREE_TRUSTED_PROXY_HOPSserver0Reverse proxies that append the client to X-Forwarded-For.
NAVREE_WORKSPACE_HEADERserverX-WorkspaceThe API's workspace.header.
NEXT_PUBLIC_PUSHER_KEY, NEXT_PUBLIC_WS_HOST, NEXT_PUBLIC_WS_PORTbuildnoneThe 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 todayWith the packages
src/lib/bff/bff.ts, http-errors.ts, server.tscreateNavree() in src/lib/navree.ts
src/app/api/[...path]/route.ts and the four src/app/api/auth/*/route.tsOne catch-all exporting navree.handlers
src/app/broadcasting/auth/route.tsexport 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.tsapplyFieldErrors from @navree/next
src/lib/api/keys.tsqueryKeys("deals") per resource
src/lib/casl/ability.tscreateAbility and AbilityProvider
src/lib/realtime/pusher.ts, mock-bus.ts, realtime-bridge.tsxRealtimeProvider, useChannel, createMemoryRealtime
src/components/data-table.tsx, pagination-bar.tsx, src/hooks/use-url-state.tsDataTable, 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:

TypeScript

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.