How I Set Up Lint Guardrails So AI-Written Code Fits the Codebase
In my last post, I wrote about 19 problems I kept finding in AI-generated frontend code. Most of them weren't bugs. The code worked. It just didn't fit the project it landed in.
This post is the follow-up: how I set up a headless ecommerce storefront (Next.js, React, TypeScript, Tailwind) so that a lot of those problems get caught automatically, whether a person or an AI wrote the code.
I'll go through each tool, what it does in this repo, and which of the 19 problems it helps with. I'll also be honest about what it can't catch.
Why instructions alone aren't enough
AI writes code based on what it has seen across lots of codebases, not on the conventions of yours. So it picks an icon library you didn't choose, hardcodes a color, or leaves a button with an empty click handler.
I use two layers to deal with that:
-
AGENTS.md describes the rules to the AI up front. Mine
covers the stack versions, the folder layering, the standards, and a
line that says to write code that passes
npm run quality:check. - Lint checks that the code followed them after it was written.
The first layer is a request. The second one is a check. I want both, but only one of them can actually stop a commit.
I should be upfront that the evidence for the first layer is mixed. A 2026 study, Evaluating AGENTS.md, found that context files generally didn't improve task success and raised cost by over 20%. Its authors say they're most useful for non-standard practices. So the right split is to keep AGENTS.md for what the AI can't work out from the code, and let lint enforce the rest.
The toolchain
Six tools, each with one job:
- Prettier: reprints code in one consistent style. The Tailwind plugin also sorts class names in a set order.
- ESLint: finds problems in code using rules you choose, and lets you write your own.
- Stylelint: the same idea for CSS.
- dependency-cruiser: checks imports against rules you write, and reports circular dependencies.
- Knip: finds unused files, dependencies and exports.
- Husky + lint-staged: run the checks on staged files on every commit.
One command runs the checks for the first five:
"quality:check": "npm run format:check && npm run lint
&& npm run stylelint && npm run deps:check && npm run unused:check"
ESLint also runs with --max-warnings=0, so a warning fails the
lint script the same way an error does.
TypeScript is part of this too. The config has strict,
noUncheckedIndexedAccess,
useUnknownInCatchVariables, noImplicitReturns and
noFallthroughCasesInSwitch turned on.
Which of the 19 problems this helps with
Wrong icon library (#19)
The guardrail: a restricted-imports rule. Importing
react-icons, lucide-react or
@heroicons/react fails lint with this message:
Use @phosphor-icons/react as the standard icon library.
Importing from the @phosphor-icons/react package root fails
too. The message points to the per-icon path.
Design inconsistencies (#14) and CSS units (#17)
The guardrails:
- No hardcoded color literals (hex, rgb, hsl, oklch) in component code.
- No arbitrary hex colors or arbitrary pixel values in Tailwind classes.
- Raw Tailwind text sizes get a warning, pointing to semantic typography classes.
-
In CSS, Stylelint bans hex colors and bans
pxfor font-size, gap, margin, padding, width and height.
"declaration-property-unit-disallowed-list": {
"font-size": ["px"],
"/^(?:gap|row-gap|column-gap|margin|padding)(?:-.+)?$/": ["px"],
"/^(?:width|height|min-width|min-height|max-width|max-height)$/": ["px"],
}
Non-functional buttons and fake links (#12)
The guardrails:
-
A custom rule that fails empty inline handlers like
onClick={() => {}}. -
A custom rule against placeholder
hrefvalues, so a link can't point at nothing. -
A custom rule that bans raw
<button>in feature components, so everything goes through the design system's Button.
Huge files (#8) and naming (#9)
The guardrails: a warning above 400 lines per file (blank lines and comments excluded), a warning above 600 lines per function, and a warning above 4 levels of nesting. Naming is checked with the TypeScript naming-convention rule.
I set these as warnings, not errors. They're a nudge to split the file,
but because the lint script fails on warnings too, they still have to be
dealt with before npm run quality:check passes.
Repeated code (#10)
The guardrails: the SonarJS plugin warns on functions with identical implementations, and on conditional branches that do the same thing.
This one is only partly covered. Lint won't find every duplicate helper. The bigger help is AGENTS.md pointing the AI to existing patterns, with a "where to copy patterns" section that names real hooks to follow.
Meta tags (#16)
The guardrails: two custom rules. One fails empty meta tag content. The other fails empty title and description values in Next metadata.
They catch empty values. They can't tell if a description is the wrong one, so copy-pasted text from a template still needs a human check.
Images (#4 and #5)
The guardrail: @next/next/no-img-element is
set to error, so a raw <img> fails lint and images go
through next/image.
Guardrails beyond the AI's mistakes
Architecture boundaries
Features that import each other turn into a tangle quickly, and AI will happily add an import if it makes the code work. So the folder layout has rules:
- The shared core layer cannot import feature modules.
- Product cannot import cart. Cart cannot import checkout.
- Lower layers cannot import from the app routes.
The eslint-plugin-boundaries plugin enforces this inside ESLint, and dependency-cruiser checks imports on its own, including circular dependencies.
Environment variables and API calls
-
Reading
process.envdirectly fails lint. Values come from two validated config modules instead. -
Components can't call
fetchor an API client directly. Requests go through services or hooks. -
Components can't call
useTranslations. Copy is resolved on the server and passed in as props.
Accessibility
The jsx-a11y rules come in through the Next.js ESLint config. On top of
that, a custom rule bans hardcoded English aria-label
strings, so screen reader text gets localized like everything else.
Write the error message as an instruction
This is the part I'd copy first. A lint error that just says "not allowed" leaves the reader to guess. A message that says what to do instead gives an AI, or a new teammate, the fix in the error itself.
Use Button, IconButton, or LinkAsButton from the design system instead of a raw <button>.
Do not hardcode color literals in component code. Use semantic design tokens from globals.css.
Do not leave empty JSX event handlers. Implement the action, disable the control, or remove the handler.
I have 13 custom rules in this repo, and most of their messages are written this way.
Guardrails for the AI tools themselves
Some of this isn't lint at all. It limits what the AI tools can do:
-
A Cursor hook that asks for my approval before any
gitcommand runs. -
A Codex permissions config that denies access to
.envfiles and asecretsfolder. -
Ignore files so the tools skip
.env.local, staging and production env files.
The commit gate
Husky runs lint-staged on every commit. lint-staged runs ESLint and Prettier on staged code files, Stylelint and Prettier on CSS, and Prettier on JSON, Markdown and YAML.
I run it with --fail-on-changes. If a fix changes your files,
the commit stops, so you can look at what changed before committing it.
"*.{js,jsx,ts,tsx}": ["eslint --fix", "prettier --write"]
What this doesn't catch
Lint checks code, not intent. These still need a human:
- Search boxes with no debounce.
- Screens that fetch far more data than they show.
- Image files that are too large.
- Whether the page matches the design.
- Whether a feature does what the customer needs.
The guardrails take the repetitive checks off the review, so review time goes to the things only a person can judge.
How to start small
You don't need all of this on day one.
- Add Prettier and ESLint, and make warnings fail the script.
- Pick the three mistakes you keep fixing in review, and write a rule for each. A restricted import is the easiest first one.
- Write each error message as an instruction.
- Let the AI run the checks itself and fix what they report. Research on static analysis feedback loops is about the model seeing the results and correcting its own code, which a check that only runs when a person remembers to run it can't give you. I've updated my own AGENTS.md to say this.
- Add Husky and lint-staged so the checks run before a commit.
- Put a short AGENTS.md in the repo that points to the same rules.
For the AI-runs-the-checks part, this is close to the wording I now use in AGENTS.md:
## Before finishing
- Run `npm run quality:check` (or `npx eslint` on the files you changed).
- Read every error, fix the code, and run it again until it passes.
- Do not add eslint-disable comments to silence a rule. Ask the user instead.
Conclusion
AI is fast, and it will follow your conventions only as far as it can see them. The guardrails make the conventions visible, and they make breaking them harder.
If you're building a store with AI in the loop and want this set up, you can see how I work on the headless storefront builds page.
Further reading
- Harness engineering for coding agent users (Birgitta Böckeler, martinfowler.com)
- Static Analysis as a Feedback Loop: Enhancing LLM-Generated Code Beyond Correctness (arXiv)
- Evaluating AGENTS.md: Are Repository-Level Context Files Helpful for Coding Agents? (arXiv)