
Last month we reviewed a client frontend that had been built almost entirely with AI assistance across a few weeks of parallel sessions. Each pull request looked fine in isolation. Components were typed. Hooks were reasonable. Naming was mostly coherent.
The app as a whole was a mess.
Three different empty-state patterns. Two form error shapes. A formatDate helper in utils, another in lib, and a third inline in a page. One feature module imported from another feature's internals. Buttons that almost matched the design system, until you scrolled to the next screen.
None of that is an AI failure. It's what happens when you ask a model to write code without a ruleset that survives the next chat window.
We've been shipping client apps at BlueLupin long enough to know: humans drift too. AI just drifts faster, and across more files, before anyone notices. The fix isn't "use AI less." It's put the rules in the repo before the AI starts writing the frontend.
Locally fine, globally wrong

File-by-file quality is easy to get from a coding agent. Global consistency is not.
An agent sees the open file, maybe a few neighbors, and whatever you pasted into the prompt. It does not hold your product's UI contract in its head the way a senior on the team does after six months. So it invents a sensible local solution every time:
- This screen needs an empty state → invent one.
- This form needs errors → invent a shape.
- This module needs a date helper → invent
formatDate.
Do that across twenty sessions and you don't have a codebase. You have a collage of locally correct decisions.
We used to blame "the model." Then we started blaming ourselves for not writing the rules down where the next session, human or AI, could find them.
What we put in the repo now
Before we let multi-session AI touch a frontend, we want three things living in the repository, not in someone's head or a Notion page nobody opens:
- A short architecture note (folder layers and import direction).
- A few non-negotiable UI contracts (forms, empty states, one component per file).
- A ruleset file the agent is told to read,
READMEsection,AGENTS.md, Cursor skill, whatever your tooling calls it.
The format matters less than the location. If it isn't in the repo, it doesn't exist for the next session.
We keep it short on purpose. A 40-page frontend guide will be ignored by humans and truncated by tools. A one-screen ruleset gets followed.
Three layers, one direction

Most of our client web apps settle into the same rough shape:
src/
common/ # shared UI, hooks, utils, no feature knowledge
modules/ # feature domains, may use common, not other modules' internals
pages/ # route-level composition, wires modules together
Names vary (shared, features, app). The rule does not:
commonnever imports frommodulesorpages.modulesmay import fromcommon, not from sibling modules' private files.pagesmay import fromcommonand from module public entry points.
That last bit is the one that saves us. When an agent needs "user list" UI, it should import from modules/users (or modules/users/index), not reach into modules/users/components/UserRowInternal. Cross-module reach-ins are how features glue themselves into a knot.
We put that directionality in the ruleset in plain language, plus one example of a good import and a bad one. Models respond well to concrete negatives.
// good
import { UserList } from '@/modules/users'
// bad, reaching into another module's internals
import { UserRow } from '@/modules/users/components/UserRow'
Is this dogmatic? A little. We've bent it for tiny apps. For anything that will live through multiple AI sessions and multiple engineers, the dogma pays for itself in review time.
One component per file (and why we still say it)
This sounds pedantic until you've reviewed a 400-line file an agent "kept extending" because the open buffer already had related UI.
One component per file forces a decision: is this a new thing, or a prop on an existing thing? Agents are terrible at that decision without a rule. Left alone, they append. Humans do the same under deadline.
We also ask for a named export that matches the file. Not because ESLint needs the hobby, because search, review, and "open symbol" all get cheaper when EmptyState.tsx exports EmptyState.
Tradeoff: more files. We accept it. Diffs are smaller. Ownership is clearer. AI sessions have a smaller surface to "helpfully" expand.
Forms and errors: pick a shape and freeze it
Empty-state drift is visible. Form error drift is worse because it breaks handling code.
We standardize early:
- Field errors live on a known shape (e.g.
Record<string, string>or whatever your form lib expects, pick one). - API validation errors map through one adapter into that shape.
- Inline field messages and a form-level banner are both allowed, but they come from the same mapped errors, not two ad-hoc parses.
In the ruleset we write something like:
> Do not invent a new form error pattern. Use mapApiErrors(error) from common/forms and pass the result into the shared Form / field components. If the API returns a new error shape, extend the mapper, don't handle it in the page.
Without that sentence, every new screen gets a bespoke if (err.response?.data?.message) block. With it, the agent usually extends the mapper or asks. Both are better than silent invention.
Empty states, loading, and "nothing here"
Design systems rarely ship every empty state a product needs. Agents fill the gap with creativity.
We've seen:
- centered gray text
- illustration + CTA
- toast that says "no data" (please don't)
- a table with a single row that says "No records found"
Pick one product pattern. Document the component. Point at it.
// preferred
<EmptyState
title="No invoices yet"
description="Create your first invoice to get started."
action={<Button onClick={onCreate}>New invoice</Button>}
/>
Same idea for loading and error boundaries at the page level. The goal isn't pixel perfection on day one. It's one door to walk through so session fifteen doesn't invent a fourth.
Duplicate helpers are a smell, not a surprise
formatDate, formatCurrency, getInitials, cn / classNames, these spawn like weeds under AI authorship because each session doesn't trust that the previous one already solved it.
Our ruleset says:
> Before adding a helper under utils or lib, search common for an existing function. Prefer extending a shared helper over adding a near-duplicate. Do not copy a helper into a module.
We also keep a short "blessed helpers" list in the architecture note. Not exhaustive, just the ones that keep getting reinvented. It's boring documentation. It works.
The ruleset that should live in the repo

Here is a condensed version of what we actually drop into client repos (trimmed for this post). It can live as a section in README.md, an AGENTS.md, or an AI skill file. The agent must be instructed to read it at the start of frontend work.
# Frontend ruleset (AI + humans)
## Architecture
- Layers: `common/` → `modules/` → `pages/`.
- Import only downward. No module-to-module deep imports.
- Export public APIs from each module's index. Pages import from there.
## Components
- One component per file. Filename matches the named export.
- Prefer composition over growing a mega-component in place.
- Reuse `common/ui` primitives. Do not restyle around them with one-off CSS unless the design calls for a new primitive.
## Forms & async UI
- Form errors: always via `mapApiErrors` + shared field components.
- Empty / loading / error: use `EmptyState`, `PageLoader`, and the standard page error view.
- Do not invent new patterns for these three.
## Helpers
- Search `common` before adding utils.
- No duplicate `formatDate` / currency / className helpers.
## When unsure
- Match the nearest existing feature module.
- Prefer a slightly awkward reuse over a clean new invention.
- Ask before introducing a new shared abstraction.
That last section matters. Models (and juniors) default to invention when reuse feels imperfect. We explicitly bias the other way.
Is this complete? No. Completeness is how rulesets die. This is enough to keep a multi-week AI-assisted build from fracturing.
What we stopped doing
We stopped pasting a novel into every prompt. Long custom instructions that repeat the design system by heart get ignored halfway through a session anyway.
We stopped assuming "the AI saw the other PR." It didn't. The other PR is on another branch, in another context window, reviewed by someone else.
We stopped treating lint as the ruleset. Lint catches syntax and some imports. It does not catch "yet another empty state" or "form errors parsed inline again." Those need prose rules next to the code.
Tradeoffs we accept
Writing the ruleset costs a half day at the start of a project, more if the design system is still moving. Sometimes product changes force us to rewrite a contract (forms especially). That's real cost.
We also accept that a strict import graph frustrates people building a spike. For spikes we say so out loud and throw the ruleset switch later. For client delivery work, we turn it on early.
The other tradeoff: agents will occasionally follow a bad rule faithfully. That's still better than inventing twenty good-looking local rules. Fix the ruleset once; don't fix twenty screens.
Review still matters, but it gets sharper
Review capacity is the bottleneck when generation is cheap. A ruleset doesn't remove review. It changes what we look for.
Without a ruleset, every PR is a negotiation about taste: How should empty states look? Where do helpers live? With a ruleset, most of that is a yes/no against a document. Reviewers spend energy on behavior, edge cases, and product fit, the stuff that still needs a human.
That's the real win for us as a product engineering team. Not faster first drafts. Fewer consistent-looking drafts that disagree with each other.
Start before the first AI commit
If you're about to spin up AI-assisted frontend work on a client app, do this in order:
- Agree the three layers and import direction. Put it in the repo.
- Freeze form errors, empty/loading/error UI, and one-component-per-file.
- Drop a short ruleset where your AI tooling will actually read it.
- Only then open the first agent session on a feature.
You can refine the ruleset as you learn. You cannot retrofit global consistency cheaply after thirty locally fine sessions have already shipped.
We've been on both sides of that retrofit. Writing the rules first is less romantic than watching the agent fly. It's also how the app still looks like one product when you demo it to the client six weeks later.