Skip to content

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/react adapter (OAuth2Provider, useOAuth2, RequireAuth).

  • You own the identity layer. An embedded OAuth2 authorization server issues the tokens, and the SPA's own /login and /signup pages are the credential surface (via the library's IdentityService). 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-slot className hooks; theme them, or pass a function as children to 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

Shell
deno run -A npm:degit udibo/oauth2/templates/react-router my-app

The @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

Shell
deno task serve   # bundle the SPA, then serve everything on port 8000

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

Shell
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 check

Project 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

  1. A signed-out visit to /dashboard triggers <RequireAuth>, which navigates to the BFF's /auth/login?return_to=/dashboard.

  2. The BFF redirects to the authorization server's /oauth2/authorize; with no issuer session, that bounces to the SPA's /login page carrying the in-flight authorize URL as return_to.

  3. The login form posts to /identity/signin. On success the server opens the issuer session and redirects into bff.loginContinuation(returnTo), which resumes the authorize URL.

  4. 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 HttpOnly oauth2_session cookie 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):

EmailPasswordNotes
[email protected]passwordEmail 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):

VariableDefaultPurpose
ORIGINhttp://localhost:8000Public origin the app is served from. Issuer, redirect URIs, and emailed links derive from it; https ⇒ Secure cookies
APP_ENVdevelopmentproduction makes deno task build minify and skips seeding the demo user
OAUTH2_CLIENT_SECRETdev-only-secretSecret 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*Service stores (oauth2/server.ts) and the in-memory session Map (sessions.ts) for persistent implementations — @udibo/oauth2/testing/contract has 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 (IdentityService accepts a rateLimiter).

  • Serve over HTTPS (cookies become Secure automatically via ORIGIN).

  • Decide how long a sign-in lasts. sessionMaxAgeMs in oauth2/server.ts is the single place that says so: it stamps the session cookie's Max-Age and 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.