ADR-Driven Frontend Platform Engineering
How five architecture decision records turned Atlas from a reusable frontend starter into a platform with documented tradeoffs, lint-enforced conventions, and request correlation through Next.js 16 proxy.ts.
- Published
- December 30, 2025
- Updated
- Updated July 12, 2026
- Author
- Daniel Mark
- Reading time
- 10 min read
I built Atlas to stop re-deciding the same frontend platform questions in every new repo. The codebase is one answer. The ADRs are the reasoning you can read without diffing the whole repo.
By late December 2025, Atlas had five accepted architecture decision records: Tailwind v4 CSS-first theming, t3-env validation, React Query plus OpenAPI, Google OAuth with PKCE, and Sentry observability. Each ADR records alternatives considered, not marketing copy. ESLint extends ADR intent into enforceable rules. Request correlation runs through Next.js 16 proxy.ts.
At this point, the ADR set covered five decisions: theming, environment validation, data fetching, auth, and observability. Consent management became a later ADR once the platform had a larger surface area.
The win is not "we wrote docs." The win is putting durable decisions where the next contributor will find them, then enforcing the outcome in lint.
Why ADRs for frontend platforms
A boilerplate answers "how do I start?" A platform answers "how do we keep making the same good decision after the person who made it is gone?"
Frontend platforms accumulate decisions that are expensive to re-litigate:
- Which cache defaults does every feature team inherit?
- Where is
fetch()allowed? - Can a component read
process.env.NEXT_PUBLIC_*directly? - Do we wrap NextAuth or own the OAuth flow?
Without ADRs, those answers live in code review muscle memory. New contributors grep for examples and copy the nearest pattern. That is how you end up with three different error shapes and a fetch() in a form component.
ADRs fix a specific failure mode: institutional memory loss. They are short, versioned markdown files with a status lifecycle: proposed, accepted, deprecated, or superseded. They do not replace code. They explain why the code looks the way it does.
Atlas indexes five accepted ADRs in docs/adr/README.md. Each follows a fixed shape: context, decision, alternatives considered, consequences. The bar for writing one is explicit: choose a library over alternatives, establish a team-wide pattern, deprecate something, or make a hard-to-reverse call. Bug fixes and obvious choices stay out. Five ADRs is not comprehensive governance. It is a curated record of the decisions that actually shape the codebase.
The five decisions at launch
ADR-0001: Tailwind v4 CSS-first theming. Design tokens live as CSS custom properties in @atlas/ui globals, with a custom useTheme hook and no next-themes or tailwind.config.js. The tradeoff is team familiarity with CSS variables over the ergonomics of a theme library.
ADR-0002: Environment validation with t3-env. Server and client variables are split into Zod schemas, validated through @t3-oss/env-nextjs, and exposed through typed env modules. CI runs pnpm validate:env. The tradeoff is friction when adding a variable versus discovering a missing secret at deploy time.
ADR-0003: React Query with OpenAPI contracts. Client-side data fetching goes through a central API client, generated types from openapi/openapi.json, query key factories, and feature hooks. ESLint blocks raw fetch() outside sanctioned modules. The OpenAPI spec uses example.com server URLs, so it should be treated as a platform contract shape, not evidence that every demo is backed by a production API.
ADR-0004: Custom OAuth with Google PKCE. Atlas skips NextAuth. It uses Authorization Code + PKCE, httpOnly cookies, CSRF state validation, and server-only session reads. The tradeoff is maintenance burden and security ownership in exchange for no abstraction layer over token handling.
ADR-0005: Observability with Sentry. Sentry is configured across client, server, and edge runtimes. A /monitoring tunnel route keeps the SDK from calling Sentry directly from the browser. ApiErrorShape includes an optional correlationId field. Sentry only runs when a DSN is configured.
Together, these cover styling, configuration, data, auth, and observability. Feature flags, form patterns, and folder ownership live in AGENTS.md and how-we-build guides, but they were not ADR-backed yet at this date.
A public summary layer
Internal ADRs live in docs/adr/. External readers get docs/public/decisions.md, which distills the same choices without making them read the full debate history.
That split matters. ADRs are allowed to be slightly boring. They preserve context, rejected alternatives, and consequences. A public decisions page should be easier to scan. It is the layer you can link from a project page, onboarding note, or portfolio write-up without forcing someone to read every internal tradeoff.
The ADRs remain the source of truth when you need the reasoning. The public summary is the map.
Lint as enforcement
ADRs decay when they are optional reading. Atlas extends two ADRs into ESLint rules in apps/web/eslint.config.mjs.
No fetch() sprawl
client.ts opens with a comment that states the rule plainly: this module is the only place allowed to call fetch() directly. The client handles auth injection, correlation IDs, retry with exponential backoff, and error normalization.
ESLint enforces that in src/app, src/components, src/features, and src/providers:
Exceptions are narrow: src/lib/api/**, src/lib/http/**, API route handlers, and the Sentry monitoring route. Infrastructure code can touch HTTP primitives. Feature code cannot.
The error message is part of the governance story. It does not just say "banned." It tells the engineer which module to use and why.
No process.env sprawl
A parallel rule blocks process.env member access in application code, with ignores for src/env/**, src/config/**, analytics adapters, and tests. A second rule discourages direct @/env imports outside the config facade.
Application code is expected to use getServerConfig() on the server and useConfig() on the client. validate:env in CI catches schema drift that ESLint cannot see. Lint stops new shortcuts. Validation stops incomplete .env files.
Beyond fetch and env
The same ESLint config bans console.* in favor of structured logging, blocks direct analytics SDK imports, and enforces accessibility rules as errors. Not every rule needs its own ADR, but the philosophy should be consistent: if the platform cares about it, CI should care about it.
A December 2025 consistency audit flagged one demo page that used raw fetch() behind an ESLint disable. That is the expected failure mode. Audits find exceptions. ESLint prevents new ones.
Alternatives considered, not marketing copy
The most valuable section in any ADR is "Alternatives Considered." That is where you learn whether the team actually weighed options or ratified a preference.
ADR-0003 documents rejected paths for data fetching: SWR, raw fetch plus useState, and tRPC. The tRPC rejection is the platform constraint in one paragraph. Atlas is built to sit in front of REST backends teams already operate. tRPC would couple the frontend platform to a specific server shape.
ADR-0004 records the same discipline for auth. NextAuth would be faster to ship, but Atlas trades that for explicit PKCE and session code.
When you read only decisions.md, you get the decision. When you read the ADR, you get the reasoning. Both belong in a platform repo.
Request context through proxy.ts
ADR-0005 observability assumes you can trace a browser error back to a server request. On Next.js 16, Atlas uses proxy.ts as the framework entrypoint for request context:
The HTTP client propagates that ID into ApiError shapes. Sentry tags match response headers.
CSP nonces are generated in the same layer, but CSP only applies when CSP_MODE is not off. Defaulting CSP to off keeps local development and incremental rollout easier. It also means you should not claim CSP hardening without checking the deployment environment.
The important part is not the file name. The important part is the path: request enters the app, receives a correlation ID, the API client carries it forward, and error UI can surface it when something breaks.
From ADR text to runtime behavior
React Query defaults are architecture. createQueryClient() sets a 60-second staleTime, 10-minute gcTime, no refetch on window focus, mount, or reconnect, and retry only for transient errors. DataProviderLayout wraps only (app) and /demo routes. Marketing pages do not pay for a QueryClient they never use.
Correlation IDs are part of the platform contract. Demo observability routes and ErrorFallback in @atlas/ui can display a correlation ID when present. That only works if the proxy layer and API client stay wired together.
Env validation is a build-time habit, not a runtime wish. The env ADR is useful because it has code next to it: schemas, config facades, and CI validation. The decision is not "use Zod." The decision is "no app path should casually read unvalidated env."
Honest limits
Five ADRs do not mean full governance. Atlas does not ADR every folder naming choice. Bug fixes and obvious refactors stay out of the index by design.
The (app) dashboard shell is not a finished product surface. ADRs document platform capabilities, not a completed SaaS dashboard.
Consent came later. ADR-0006 and @atlas/consent landed after the initial ADR set. That belongs in a later platform story, not in the December 2025 snapshot.
Demo routes prove integration, not production hardening. /demo/* exercises auth, data, forms, flags, and observability. Demos are legitimate engineering artifacts. They are not load-tested product features.
OpenAPI types are codegen artifacts. useUserList imports generated types. The spec is maintained in-repo. That does not mean every demo page hits a live multi-tenant API.
Self-audit culture exists but is not certification. A consistency audit is a hygiene document, not an external audit result.
How to adopt a minimum viable ADR set
You do not need five ADRs on day one. You need the habits behind them.
Start with decisions that generate recurring debate or repeated code review comments:
- Data fetching. Pick one client cache library. Write one ADR comparing realistic alternatives for your backend shape. Add one ESLint rule banning raw
fetch()in feature code. Ship a singleclient.tswith shared error normalization. - Configuration. Pick t3-env or an equivalent schema validator. Document server/client split. Ban direct
process.envin application paths. Run validation in CI. - One cross-cutting concern you actually operate. For Atlas that was observability: Sentry plus correlation IDs. For your team it might be auth or analytics. Write the ADR when you choose the tool, not six months later.
Add a public decisions summary when external readers need the thesis without internal debate context. Keep ADRs in-repo with the implementation PR that fulfills them. Status lifecycle should be real: proposed, accepted, deprecated, superseded.
Run a consistency audit periodically. Table canonical import paths. Grep for ESLint disables. Check whether demo routes still reflect current conventions.
What I would improve next
- Write ADR-0007 when dashboard routes ship, or explicitly mark (app) as demo-only scaffold.
- Add ADR coverage for enforced but undocumented patterns, such as feature flags and route-scoped providers.
- Keep the public decisions summary aligned with the ADR index as the platform evolves.
- Review demo routes after major refactors so examples do not drift away from the rules they are meant to prove.
- Add a short "when to write an ADR" checklist to pull request guidance.
None of those change the core lesson: documentation is stronger when the repo enforces the decisions it documents.
Closing
A frontend platform is not only packages and demo routes. It is decisions that survive turnover. Five ADRs plus ESLint enforcement plus request correlation in proxy.ts is the minimum platform behavior I wanted Atlas to demonstrate: document the tradeoff, then make the wrong path expensive.
That is the layer I want in place before the next architectural debate reopens in Slack.
Written by
Daniel Mark
Senior Frontend Engineer
Daniel Mark is a senior frontend engineer and product consultant with 7+ years of experience building resilient product interfaces for Web3, SaaS, and enterprise teams. He writes about frontend architecture, design systems, performance, SEO, and the engineering decisions that turn complex systems into dependable user experiences.