React Router app with auth preconfigured
Your app: a React Router framework mode app with
server-side rendering, served by a Hono server on Node
through react-router-hono-server,
built with Vite, Tailwind CSS 4 and the React Compiler. A complete
authentication layer from @udibo/oauth2 is
already wired up:
Sign-up, sign-in, sign-out, and a protected page work out of the box.
The browser never holds a token. The server runs the OAuth2 Backend-For-Frontend (BFF) pattern: tokens live in a server-side session behind an HttpOnly cookie, and the app reads auth state through the
@udibo/oauth2/reactadapter (OAuth2Provider,useOAuth2).Pages render signed in on the first paint. The server reads the BFF session for each request (
bff.readSession), passes it to React Router through the load context, and the root route seedsOAuth2Providerwith it, so there is no signed-out flash and no session probe on load.You own the identity layer. An embedded OAuth2 authorization server issues the tokens, and the app's own
/loginand/signuppages are the credential surface (via the library'sIdentityService). No third-party IdP in the loop.The auth pages use the package's own components. Sign-in, sign-up, and both password-reset pages render
@udibo/oauth2/react/components(SignInForm,SignUpForm,RequestPasswordResetForm,ResetPasswordForm), so you inherit their accessibility — programmatic labels,aria-invalid/aria-describedby, focus moved to the first errored field, a re-announced error summary — instead of hand-rolling it. They ship unstyled with per-slotclassNamehooks; theme them, or pass a function aschildrento keep the wiring and own the markup.
Scaffold
Copy this directory, or:
npx degit udibo/oauth2/templates/react-router my-appPoint @udibo/oauth2 at a published version
Inside this repository package.json depends on the package through
"@udibo/oauth2": "workspace:*", which resolves to the repository's own build.
Everything else in package.json is a plain version range, so a copy outside
the workspace needs one edit: replace that line with a version range.
"@udibo/oauth2": "^0.15.0"Until the package is on npm, the JSR registry serves it under an npm name; add
@jsr:registry=https://npm.jsr.io to an .npmrc and use
"@udibo/oauth2": "npm:@jsr/udibo__oauth2@^0.14.1".
Tests use MSW 3, which Vitest 5 does not list among its accepted peer ranges.
If your package manager warns about it, allow it (pnpm:
peerDependencyRules.allowedVersions for @vitest/mocker>msw set to 3) or
pin msw to a 2.x release.
First run
Requires Node 24.2 or later.
pnpm install
pnpm dev # http://localhost:8000, with hot reloadingOpen http://localhost:8000/, click Dashboard, and sign in as the seeded
demo user (demo@example.com / password) — or create an account and watch the
server console for the email-verification link.
For production:
pnpm build
pnpm start # node build/server/index.jsOther scripts:
pnpm test # server tests (no build or network) and component tests (jsdom + MSW)
pnpm typecheck # react-router typegen, then tscIn development, editing a server file reloads the server module, which resets the in-memory stores (accounts and sessions) — sign in again after a server change.
Project tour
server/
index.ts # the server entry: logs requests (query values redacted), mounts the auth
# layer, hands the rest to React Router
auth.ts # /oauth2, /auth, /identity, /api as one Hono app (tested with app.request)
api.ts # your protected API (/api/*), guarded by bff.protect()
config.ts # zod-validated environment (see below)
sessions.ts # the issuer's login session (in-memory demo store)
load-context.ts # builds the router context: BFF session + demo-account flag
oauth2/
server.ts # authorization server + BFF + resource server, one process
identity.ts # sign-up/sign-in/reset/verify flows + console "mailer"
app/
root.tsx # html shell, <OAuth2Provider> seeded from the load context
context.ts # the typed router context the server provides
routes.ts # layout, home, login, signup, dashboard, verify-email,
# forgot-password, reset-password
routes/ # one module per page, each with a colocated *.test.tsx
lib/identity.ts # posts the login/signup forms to /identity/*
oauth2/browser-client.ts # browser-safe BffClient for the React adapter
test/ # shared test setup, MSW server, route-stub renderer, server-build stubHow a sign-in flows
A signed-out request for
/dashboardhits the route'sloader, which redirects (a document redirect, so the browser leaves React Router) to the BFF's/auth/login?return_to=/dashboardbefore any HTML is sent.The BFF redirects to the authorization server's
/oauth2/authorize; with no issuer session, that bounces to the/loginpage carrying the in-flight authorize URL asreturn_to.The login form posts to
/identity/signin. On success the server opens the issuer session and redirects intobff.loginContinuation(returnTo), which resumes the authorize URL.The authorize endpoint issues a code to the BFF's
/auth/callback, which exchanges it in-process and stores the tokens in the server-side session — the browser gets only the HttpOnlyoauth2_sessioncookie and lands back on/dashboard.
An authorize request with prompt=login or max_age asks for a fresh sign-in.
The authorize handler in server/auth.ts reports when the issuer session signed
in as auth_time. It reports reauthenticated: true only when the session
signed in for this exact authorize URL within the last ten minutes, and
otherwise sends the user to /login again.
Signing out via the header button hits /auth/logout, which revokes the refresh
token and clears both sessions.
The loader is what protects the page. A rendering guard such as
<RequireAuth> is for client-rendered apps; here the check runs on the server,
and /api/* is guarded separately by bff.protect().
Seed & configuration
Seeded account (in-memory, recreated on every restart; skipped when
APP_ENV=production so no known credential ships to a live deploy):
| Password | Notes | |
|---|---|---|
demo@example.com | password | Email already marked as verified |
Email delivery is the server console. Sign-up prints the verification link
(/verify-email?token=…) to the terminal running the server, and Forgot your
password? on the sign-in page prints a reset link (/reset-password?token=…)
the same way — both links land on real pages that consume the token. Swap the
two hooks in server/oauth2/identity.ts for your mailer.
Environment variables (all optional; see .env.example, loaded with
--env-file-if-exists when a .env exists). server/config.ts validates them
once at startup and lists every invalid variable at once:
| Variable | Default | Purpose |
|---|---|---|
ORIGIN | http://localhost:8000 | Public origin the app is served from. Issuer, redirect URIs, and emailed links derive from it; https ⇒ Secure cookies |
PORT | 8000 | Port pnpm start listens on. pnpm dev uses the port in vite.config.ts |
APP_ENV | development | production skips seeding the demo user. Independent of NODE_ENV |
OAUTH2_CLIENT_SECRET | dev-only-secret | Secret for the app's OAuth2 client. Required when APP_ENV=production (no fallback) |
Everything is hermetic: in-memory stores, no database, no external services.
Going to production
The demo shortcuts to replace before shipping:
Swap the
Memory*Servicestores (server/oauth2/server.ts) and the in-memory sessionMap(server/sessions.ts) for persistent implementations —@udibo/oauth2/testing/contracthas conformance tests for yours.The BFF session store.
server/oauth2/server.tspassesHonoBffaMemorySessionStore, separate from the issuer's loginMapinsessions.ts, so a restart signs everyone out and a second instance does not know the sessions the first holds. Replace it with a shared stateful store, orEncryptedCookieSessionStorewhen its revocation and cookie limits suit your app.Pending browser login. The BFF's
authRequestStorageis aMemoryAuthRequestStorageFactory, which keeps each pendingstateand PKCE verifier in this process, so a callback that reaches another instance fails. Replace it withEncryptedCookieAuthRequestStorageor a factory over shared storage. See environment configuration for both options.Set
OAUTH2_CLIENT_SECRET— the app refuses to start in production without it, since the development fallback is public in the template source.Wire a real mailer into
server/oauth2/identity.ts, and add rate limiting on the identity endpoints (IdentityServiceaccepts arateLimiter).RateLimiterandAccountLockoutrequire astore: give them aRateLimitStoreand aLockoutStorethat every instance shares, or the memory stores for a single process only.Keep request logs free of credentials.
server/index.tsturns offreact-router-hono-server's default logger, which prints each raw query string — reset and verification tokens, callback codes — and mountsrequestLogger()from@udibo/oauth2/hono/log, which logs every query value as[redacted]. If you switch to your own logger, record the target fromredactedRequestTarget(c.req.url)instead of the raw URL.Serve over HTTPS (cookies become
Secureautomatically viaORIGIN).Decide how long a sign-in lasts.
sessionMaxAgeMsinserver/oauth2/server.tsis the single place that says so: it stamps the session cookie'sMax-Ageand bounds the session server-side. The template states the package default of 14 days explicitly so the knob is visible; raise it for a longer "stay signed in", and if you pass your own session store with its own maximum age, keep the two in agreement — the BFF refuses a configuration whose cookie would expire before the session does.Pages that depend on the session send
Cache-Control: private, no-store(the root route'sheadersexport). Keep that on any page you render from the session.

