A Strict Path to Speed — Orval, React Query, BFF and DepCruiser + Biome
6 min read

A Strict Path to Speed — Orval, React Query, BFF and DepCruiser + Biome
Context & Scope
We (Cursor and I) have migrated the NestJS backend to OAuth 2.1—Cursor handled the vibes, I handled the stack traces. The next step is aligning our Next.js frontends behind a BFF (Backend‑for‑Frontend) proxy implemented as a single Route Handler: app/api/[...proxy]/route.ts. This post documents the current architecture (Next.js 16 + React 19.2, Orval, Zod, React Query, RHF, shadcn/ui, Biome), the boundaries we enforce, and the bits we’re intentionally keeping flexible so we can iterate without drama. If you see something opinionated, that’s the point—strict where it matters, chill where it helps us ship.
Bonus context: our goal is simple—move fast without turning the repo into spaghetti. We let the BFF and Orval do the heavy lifting so features stay boring (in the best way).
Open Areas (What We’re Willing to Adjust)
We aim to keep these parts flexible while we gather production feedback:
- Feature boundaries & naming: how we slice
features/*and when to promote shared pieces intocomponents/orlib/. - BFF responsibilities: the exact split between the BFF route and
lib/api/mutator*(e.g., refresh flow, retries, tracing, request normalization). - Token strategy: access/refresh lifetime, storage medium, rotation cadence, and failure handling UX.
- Error & observability policy: mapping errors from BFF → UI, structured logs/trace IDs, and user‑visible toasts.
- Query keys & caching: key factories, invalidation conventions, background refetch defaults (React Query remains the single source of truth).
- Schema usage: when we rely on Orval‑generated Zod schemas “as is” vs. when a feature‑level form schema composes or refines them.
- Route‑group layout policy: scope of
(public)/(private)layouts and any per‑area providers. - Shared UI: what belongs in
components/vs. feature‑local UI; rules for prop shapes when passing Orval types to dumb components. - Guardrails: strictness of Dependency Cruiser rules and Biome settings; local/CI client tooling that runs both checks before merge.
The Philosophy: Pragmatism Over Dogma
Our stack is built for speed and type‑safety:
- Next.js App Router enforces Server/Client boundaries and routing.
- Orval generates type‑safe API clients, Zod schemas, and React Query hooks from our OpenAPI spec.
- React Query is the source of truth for client‑side server state.
- React Hook Form (RHF) handles complex form state and pairs cleanly with Zod.
- Zod is our source of truth for validation (both API‑driven and form‑driven).
- shadcn/ui provides composable, accessible UI components.
- Biome is our linter & formatter (replacing ESLint and Prettier).
Our pragmatic stance:
- Your API Contract is Your Abstraction. Orval’s React Query hooks and Zod schemas are not “low‑level details.” They are the strongly typed contract between frontend and backend. We use them directly—including inside UI components.
- Decouple features, not tools. We don’t try to “abstract away Orval.” We care about decoupling post‑management from user‑settings.
- Golden boundary —
features/vs.lib/&components/.features/: Anything domain‑specific. If a hook, component, or schema understands what a Post or a User is, it belongs here (e.g.,features/post-management/).lib/&components/: Anything domain‑agnostic. If a hook, component, or utility is truly generic and reusable (e.g.,useTheme,FileUploader), it lives here.
Part 1: File Structure
/├── app/ # 1. The Router (what routes exist?)├── components/ # 2. Reusable UI (what does it look like?)├── features/ # 3. Business features (what does it do?)├── lib/ # 4. Reusable logic (what tools does it use?)├── kodkafa/ # 5. API contract (what is the data shape?)├── config/ # 6. Guardrails (what are the rules?)└── styles/
1) app/ (The Router)
Rule: app/ files (page.tsx, layout.tsx) do nothing but render the primary component from features/. They do not fetch data. The feature component (a Client Component) is responsible for its own data fetching via Orval hooks.
Route Groups: (private) and (public) are Next.js Route Groups. They don’t affect the URL, but let us apply separate layouts.
app/├── (private)/ # Area requiring authentication│ ├── layout.tsx # Auth layout (e.g., redirects to /login if no session)│ └── posts/│ ├── page.tsx # Renders <PostListFeature />│ ├── new/│ │ └── page.tsx # Renders <PostFormFeature />│ └── [id]/edit/│ └── page.tsx # Renders <PostFormFeature />├── (public)/ # Publicly accessible group│ ├── layout.tsx # Shared <Header /> and <Footer />│ ├── [category]/│ │ └── page.tsx # Renders <PublicPostListFeature />│ └── about/│ └── page.tsx├── layout.tsx # Root layout (global providers only)└── error.tsx # Global error boundary
Note: We use the BFF pattern via a single Route Handler:
app/api/[...proxy]/route.ts. Client‑side API requests flow through this endpoint using Orval’s generated clients and custom mutators.
2) components/ (Reusable UI)
Rule: Components here are dumb. They never import from features/, app/, or kodkafa/. They receive all data as props. Using Orval‑generated types in props is allowed—the API contract is our source of truth.
components/├── ui/ # shadcn/ui compositions├── layout/ # App-level skeleton UI (Header, Footer)│ ├── header.component.tsx│ ├── footer.component.tsx│ └── main-nav.component.tsx└── common/ # Generic, reusable UI├── file-uploader.component.tsx└── theme-switcher.component.tsx
3) features/ (Business Features)
Rule: “Smart” Client Components live here. They may import directly from kodkafa/ (Orval hooks & schemas) and lib/ (global hooks/utilities). Domain‑specific forms, lists, and views belong here.
features/└── post-management/├── components/│ ├── post-list.component.tsx # Smart: calls Orval's useGetPostsInfiniteQuery()│ ├── post-form.component.tsx # Smart: RHF + Zod + useCreatePostMutation()│ └── post-files.component.tsx└── schemas/└── post-form.schema.ts # Form schema (may reuse Orval schemas directly)
4) lib/ (Reusable Logic)
Rule: Generic, domain‑agnostic. Must never import from features/ or app/.
lib/├── api/│ ├── mutator.ts # Server-side mutator (auth/token attach, retries)│ └── mutator-client.ts # Client-side mutator (direct to upstream origin)├── auth/│ └── token.utils.ts # Token acquire/refresh helpers├── hooks/│ ├── use-error-boundary.hook.ts│ └── use-theme.hook.ts├── providers/│ ├── theme.provider.tsx│ ├── i18n.provider.tsx│ └── query.provider.tsx # React Query <QueryClientProvider> wrapper├── query/│ └── query-opts.ts # Global React Query defaults (staleTime, etc.)├── store/│ └── system.store.ts # Global toasts, etc.├── i18n/│ └── ...└── utils/└── validation.utils.ts
5) kodkafa/ (The API Contract)
Auto‑generated by Orval. The Orval config imports our custom mutators from lib/api/.
kodkafa/└── client/├── schemas/ # Auto-generated Zod schemas from OpenAPI│ └── post.zod.ts└── posts.ts # Auto-generated React Query hooks & clients├── useGetPostsInfiniteQuery└── useCreatePostMutation
6) config/ (Guardrails)
Holds biome.json, next.config.js, orval.config.js, and depcruise.config.cjs.
Part 2: SOLID Principles in This Stack
S — Single Responsibility Principle (SRP)
app/(private)/layout.tsx: Reason to change → authentication/layout logic changes.features/post-management/post-form.component.tsx: Reason to change → business logic for creating a post changes.lib/api/mutator*.ts: Reason to change → the policy for attaching tokens/retries changes.components/common/file-uploader.component.tsx: Reason to change → UI for uploading files changes.
O — Open/Closed Principle (OCP)
- New feature? Add
features/user-settings/. Existing features remain closed to modification. - New global hook? Add it in
lib/hooks/.
L — Liskov Substitution Principle (LSP)
- Enforced by Orval’s TypeScript types and Zod schemas generated from the API contract.
I — Interface Segregation Principle (ISP)
- Dumb UI components receive only the fields they actually render. Passing entire records is allowed iff the UI truly needs all those fields; otherwise pass a narrowed shape. Orval types/schemas are allowed in UI props.
D — Dependency Inversion Principle (DIP)
- Data access abstraction = Orval (hooks/clients). Cross‑cutting policy abstraction = lib/api/mutator*.
Part 3: Critical Implementation Details
Authentication (OAuth 2.1)
- Client requests route through the BFF at
app/api/[...proxy]/route.tsusing Orval clients and ourcustomInstancemutators. mutator-client.tsconsultslib/auth/token.utils.tsto retrieve/refresh JWTs; the BFF handles refresh/rotation as needed.- Feature components have no knowledge of token/refresh flows.
Default token stance: Prefer HTTP‑only, Secure cookies for refresh tokens; short‑lived access tokens are attached by the BFF. This reduces XSS exposure and simplifies rotation.
Zod & Schemas
- API Schemas (Read‑Only):
kodkafa/client/schemas/*.zod.tsare generated by Orval and are the single source of truth. We can use these schemas directly in UI and forms. - Form Rules: If a form needs extra UI‑only constraints, define them in
features/*/schemas(optionally composing with the API schema). Otherwise, prefer using the generated schemas as‑is.
Caching & Invalidation
- React Query is the single source of truth for client‑side server state.
- No Next.js tag revalidate/update calls. Invalidation is handled via React Query (e.g.,
invalidateQueries). - Query keys should be centralized (e.g., in
lib/query/query-opts.tsor via a dedicated key factory).
React Compiler (Next.js 16 + React 19.2)
- We assume Next.js 16 on React 19.2 with the React Compiler enabled via
next.config.js. - To benefit from automatic memoization, write pure, side‑effect‑free render logic—especially in
components/.
Part 4: The Enforcers — Biome & Dependency Cruiser
Biome handles formatting and general linting. Dependency Cruiser enforces architectural boundaries.
config/depcruise.config.cjs
module.exports = {forbidden: [{name: 'lib-cannot-import-features-or-app',severity: 'error',from: { path: '^lib' },to: { path: ['^app', '^features'] },},{name: 'components-cannot-import-logic',severity: 'error',from: { path: '^components' },to: { path: ['^app', '^features', '^kodkafa', '^lib'] },},{name: 'features-cannot-import-app',severity: 'error',from: { path: '^features' },to: { path: '^app' },},{name: 'no-cross-feature-imports',severity: 'error',from: { path: '^features/([^/]+)' },to: { path: '^features/([^/]+)', pathNot: '^features/$1' },},{name: 'generated-code-restrictions',severity: 'error',from: { path: '^kodkafa' },to: { path: ['^app', '^features', '^components'] },},],options: {tsConfig: { fileName: 'tsconfig.json' },moduleSystems: ['amd', 'cjs', 'es6', 'tsd'],includeOnly: '^(app|features|lib|components|kodkafa)/',exclude: ['^node_modules'],},};
Part 5: Cursor MDC (Pair‑Coding Operating Rules)
File: .cursor.mdc
---alwaysApply: true---# Core Development Rules (Project-Wide)<context>Pragmatic Next.js architecture with a BFF proxy. We (Cursor & I) enforce strict boundaries to ship fast without codebase drift.</context><rule id="golden-boundaries" severity="error"><title>Golden Boundaries</title><instructions>- Do **not** write data fetching in app/ pages/layouts. Always render a feature component.- Use the **BFF pattern** via app/api/[...proxy]/route.ts. All client API requests go through this Route Handler using Orval clients + lib/api/mutator*.- features/ = domain‑specific. lib/ & components/ = domain‑agnostic.- UI components may use **Orval‑generated types/schemas directly** as props.</instructions></rule><rule id="data-access" severity="error"><title>Data Access</title><instructions>- Use Orval‑generated React Query hooks/clients. Do **not** wrap them with redundant custom hooks unless adding real value.- Token/refresh/retry logic must go through lib/api/mutator*.ts and the BFF route.</instructions></rule><rule id="caching" severity="error"><title>Caching</title><instructions>- Invalidate via React Query only. **Do not** introduce Next.js tag invalidation. (Tags come from API doc; no extra revalidate.)</instructions></rule><rule id="react-compiler" severity="warning"><title>React Compiler</title><instructions>- Assume Next.js 16 + React 19.2 with Compiler enabled. Write pure, side‑effect‑free render logic.</instructions></rule><rule id="pull-request-checklist" severity="error"><title>Pull Request Checklist</title><instructions><checklist><item>No imports from features/ or app/ in lib/ or components/.</item><item>app/ pages render exactly one feature component; no fetching in app/.</item><item>Orval hooks used directly; no useless wrapper hooks.</item><item>UI components receive only the fields they render; Orval types are allowed in props.</item><item>React Query invalidations are present after mutations where needed.</item><item>Run Biome: pnpm biome check . (or npx @biomejs/biome check .).</item><item>Run Dependency Cruiser: pnpm depcruise -c config/depcruise.config.cjs --include-only '^(app|features|lib|components|kodkafa)/' .</item><item>Client tool run (local or CI): script combines Biome + DepCruise, e.g. pnpm -r lint:arch.</item></checklist></instructions></rule>
Conclusion: Strict Boundaries, Saner Days (Cursor & I)
I started using Cursor this year. Model to model it varies, but the pattern was familiar: lots of reminding what should happen next, explaining why certain outputs were wrong, and occasionally debating with a model that wouldn’t admit the miss. “You’re right” became my least-favorite auto-reply.
This architecture is how I buy back headspace: tighter boundaries, fewer surprises, faster delivery. If it works as intended, Cursor and I ship at speed without turning the repo into spaghetti.