React Router SPA with auth preconfigured
Your app: a plain React Router data mode SPA
(createBrowserRouter — no framework mode, no SSR) served by a
Hono server on Deno, with a complete authentication layer
from @udibo/oauth2 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 SPA reads auth state through the
@udibo/oauth2/reactadapter (OAuth2Provider,useOAuth2,RequireAuth).You own the identity layer. An embedded OAuth2 authorization server issues the tokens, and the SPA'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.
This is the right shape when your React app is a SPA and your server is Deno. If
you want server-side rendering, start from a
Juniper template instead and pair it with
the examples/juniper wiring in this repository.
Scaffold
deno run -A npm:degit udibo/oauth2/templates/react-router my-appThe @udibo/oauth2 import is already pinned in deno.json
(jsr:@udibo/oauth2). Inside this repository it resolves to the workspace copy;
a standalone scaffold resolves it from JSR once the package is published.
First run
deno task serve # bundle the SPA, then serve everything on port 8000Open http://localhost:8000/, click Dashboard, and sign in as the seeded
demo user ([email protected] / password) — or create an account and watch the
server console for the email-verification link.
For development:
deno task dev # serve with server auto-restart
deno task build --watch # (second terminal) rebuild the browser bundle on change
deno task test # hermetic server tests, no build or network needed
deno task check # type check + lint + format checkProject tour
main.ts # the server: SPA shell + static files + all auth mounts
api.ts # your protected API (/api/*), guarded by bff.protect()
build.ts # esbuild bundler for the browser app
config.ts # env-driven configuration (see below)
sessions.ts # the issuer's login session (in-memory demo store)
oauth2/
server.ts # authorization server + BFF + resource server, one process
identity.ts # sign-up/sign-in/reset/verify flows + console "mailer"
browser-client.ts # browser-safe BffClient for the React adapter
client/
main.tsx # createBrowserRouter + <OAuth2Provider>
env.ts # browser-visible flags injected into the shell
identity.ts # posts the login/signup forms to /identity/*
routes/ # layout, home, login, signup, dashboard, verify-email,
# forgot-password, reset-password. The four auth
# pages render the package's form components.
main.test.ts # in-process tests over the whole auth surface
public/build/ # generated by `deno task build` (gitignored)How a sign-in flows
A signed-out visit to
/dashboardtriggers<RequireAuth>, which navigates to the BFF's/auth/login?return_to=/dashboard.The BFF redirects to the authorization server's
/oauth2/authorize; with no issuer session, that bounces to the SPA's/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.
Signing out via the header button hits /auth/logout, which revokes the refresh
token and clears both sessions.
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 | |
|---|---|---|
[email protected] | 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 SPA pages that consume the token. Swap
the two hooks in oauth2/identity.ts for your mailer.
Environment variables (all optional; see .env.example, loaded via
--env-file when a .env exists):
| 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 |
APP_ENV | development | production makes deno task build minify and skips seeding the demo user |
OAUTH2_CLIENT_SECRET | dev-only-secret | Secret for the SPA'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 (oauth2/server.ts) and the in-memory sessionMap(sessions.ts) for persistent implementations —@udibo/oauth2/testing/contracthas conformance tests for yours.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
oauth2/identity.ts, and add rate limiting on the identity endpoints (IdentityServiceaccepts arateLimiter).Serve over HTTPS (cookies become
Secureautomatically viaORIGIN).Decide how long a sign-in lasts.
sessionMaxAgeMsinoauth2/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.

