Most of my recent projects have been built with an AI pair in the room. They include a device-financing platform with a public site, an admin portal, an API and a mobile app, and a café website with a menu, cart and checkout. That AI pair is Claude Code, working in the same repository I work in, running the same commands, reading the same files.
What made that work was not a clever prompt. It was structure. Every project follows the same rules for naming, layout, data and content, and those rules are written down where the AI reads them before it touches anything. When the structure is predictable, the AI's output is predictable too. When it isn't, you spend your day fixing confident code in the wrong place.
This is how I set that up.
The stack I reach for
For web work my default is:
- TanStack Start for server rendering, on Vite and React 19
- TanStack Router with file-based routes
- TanStack Query for server state, wired into SSR
- TanStack Store for the little client state that is left (a cart, a drawer flag)
- Tailwind CSS v4 with shadcn/ui primitives, all colours as CSS variables
- zod for validation, lucide-react for icons
- pnpm, always
On the backend it's NestJS with Prisma and zod, and on mobile it's Expo with expo-router. The specific stack matters less than the fact that it is the same stack every time. The AI never has to guess which router or which data library this project uses.
Rule one: file names are boring on purpose
Every file name is kebab-case, all lowercase, words separated by hyphens.
components/menu/menu-item-row.tsx
components/cart/quantity-stepper.tsx
lib/format-price.ts
hooks/queries/menu.query.ts
hooks/mutations/orders.mutation.ts
No MenuItemRow.tsx, no menuItemRow.tsx, no utils2.ts. The reason is not taste. A single convention means a file can be found by guessing its name, and a model generating new files produces names that match the ones already there. Mixed casing is also how you get a build that passes on macOS and fails on Linux.
Data hooks keep a domain suffix: .query.ts for reads and .mutation.ts for writes. You can tell what a file does from its name before opening it.
Rule two: one component per file
A file declares exactly one React component. If a card needs a sub-component, that sub-component gets its own file and is imported. Behavioural hooks are the same: use-scrolled.ts, use-marquee.ts, use-circle-reveal.ts, one hook each.
This is the rule that pays off most with an AI. Small, single-purpose files are cheap to read, so the model reads the whole thing instead of skimming. Edits stay local. Reviews stay small. And when I ask for "the loyalty step card", there is one obvious file to change.
Rule three: comments explain why, in two lines or fewer
Comments are capped at two lines, and they explain why, never what. The code already says what.
// Close on finish, with a timer backstop in case the browser throttles the animation.
const timer = setTimeout(done, CLOSE_MS + 100)
The only exception is a short doc header on hooks and services, so authorship and purpose are visible at the top:
/**
* Reads the home page promo banners from the mock-data seam.
* @author Joseph Nartey
* @github devjoemedia
*/
Left alone, AI-written code tends towards paragraphs of narration above every block. A hard limit keeps the signal high.
Rule four: imports use an alias, never a ladder
Everything under src/ is imported through #/:
import { cn } from '#/lib/utils'
import { usePromosQuery } from '#/hooks/queries/promos.query'
No ../../../lib/utils. Moving a file never breaks its imports, and every import in the codebase reads the same way.
The folder layout
Every web project has the same shape:
src/
├── routes/ file-based routes
├── components/ grouped by page (home/, menu/, checkout/…)
│ ├── common/ shared across pages
│ ├── layout/ the app shell
│ └── ui/ shadcn primitives
├── content/ static page copy, typed (the CMS seam)
├── data/ dynamic records, typed (the API seam)
├── hooks/
│ ├── queries/ reads, grouped by domain
│ └── mutations/ writes, grouped by domain
├── lib/ utils, errors, http-client, seo
├── stores/ TanStack Store slices
├── constants/ cache times, site identity, feature flags
├── integrations/ provider wiring
└── types/ domain models, the single source of truth
The two folders that matter most are content/ and data/.
Nothing user-visible is hardcoded in a component
This is the core rule. Headlines, button labels, hero text, FAQ entries, nav links, testimonials, menu items: none of it lives in JSX. It lives in typed .ts or .json files, and every shape has an interface in src/types/.
src/content/holds page copy: headings, blurbs, labels, step lists.src/data/holds records: menu items, reviews, promo banners, orders.
Content modules follow a few rules so a CMS can take them over later:
- Typed and flat. Plain serialisable objects only: strings, numbers, arrays. No JSX, no functions, no imported components.
- Icons are string keys. Content says
icon: 'croissant', and the view maps that key to a component. - Stable identity. Every record has an
idorslug, and ordered lists carry an explicitorder. These become document IDs in the CMS. - One shape per content type. A block uses the same interface everywhere it appears.
// src/data/promos.ts
const PROMOS: Array<PromoSlide> = [
{ id: 'summer-shake', order: 1, alt: 'Summer shake offer', href: '/menu', image: art(1) },
{ id: 'student-deal', order: 2, alt: 'Student lunch deal', href: '/menu', image: art(2) },
]
export const getPromos = () => [...PROMOS].sort((a, b) => a.order - b.order)
When the client says "can we change the button to say Order now", it's a one-line edit in a content file. When they say "we're moving to Sanity", it's a change to one function per domain, with no component edits at all.
Data fetching: one seam per domain
Components never import mock data directly. Every dynamic read goes through a query hook, and every write goes through a mutation hook. Each fetch function is wrapped in an error handler and calls a src/data accessor.
// src/hooks/queries/promos.query.ts
export const promoKeys = {
all: ['promo'] as const,
list: () => [...promoKeys.all, 'list'] as const,
}
const fetchPromos = withErrorHandling(async () => {
await new Promise((r) => setTimeout(r, 150)) // simulate latency
return getPromos() // ← swap for httpClient.get('/promos')
}, 'Failed to load promotions')
export const promosQueryOptions = () =>
queryOptions({
queryKey: promoKeys.list(),
queryFn: fetchPromos,
staleTime: DEFAULT_STALE_TIME,
gcTime: DEFAULT_GC_TIME,
})
export const usePromosQuery = () => useQuery(promosQueryOptions())
A few details make this hold up:
- Query keys live in a factory per domain (
promoKeys), so invalidation never depends on a string typed twice. queryOptionsis exported, so the route loader and the hook share one cache entry.- Simulated latency in the mock means loading states are real from day one. You find the missing skeleton now, not after launch.
withErrorHandlingturns timeouts, network failures and server messages into errors that are safe to show a user. Components never see a raw stack trace.httpClientalready exists, with timeouts and retries. Going live means changing the body offetchPromosand nothing else.
Routes server-render by ensuring the data in the loader:
export const Route = createFileRoute('/')({
loader: ({ context }) => context.queryClient.ensureQueryData(promosQueryOptions()),
head: () => seo({ title: 'Home', description: '…', path: '/' }),
component: HomePage,
})
The page arrives with its content in the HTML. Crawlers see it, and there is no loading flash on first paint.
SEO is part of the definition of done
Every route sets its own title, description, canonical URL, Open Graph and Twitter tags through one seo() helper. Site identity lives in constants/site.ts. Private pages like the cart and checkout pass noindex. Structured data (a CafeOrCoffeeShop, a Menu) is generated from the same data files the UI reads, so it can't drift from what's on the page.
Design tokens, not hex codes
Colours, radii and fonts are CSS variables in styles.css, mapped into Tailwind with @theme inline. Components use bg-primary and text-muted-foreground, never #d2461a. When a product has several surfaces (a website, an admin portal, a mobile app), one shared DESIGN-SYSTEM.md at the repository root defines the tokens, and each app points to it. When the tokens in two apps drift apart, the fix is written down and applied to all of them.
This is also what makes design changes cheap. When a client decided they didn't like rounded corners, the fix was a handful of token values, and it was easy to undo when they changed their mind for buttons.
How Claude Code fits in
Here is the part people ask about.
CLAUDE.md is the contract
Every repository has a CLAUDE.md at its root. Claude Code reads it at the start of every session. Mine always has the same sections:
- What the product is, in two sentences, and its core domains
- The stack
- The commands (
dev,build,lint,format,tsc --noEmit) - File and code conventions: everything above, stated as rules
- Folder structure
- How providers are wired, and what not to add twice
- The data and content seam, with a worked example
- Styling and design tokens
- SEO requirements
- Which skills to use
- A definition-of-done checklist
The rules are written as rules ("one component per file, always"), not suggestions. Anything I find myself correcting twice goes into this file.
Skills carry the craft
On top of CLAUDE.md, each frontend project installs three agent skills into .claude/skills/:
- frontend-engineer. My own workflow. Before building anything non-trivial: the user's goal, every state (loading, empty, error, success, edge cases), the component structure, responsive behaviour and accessibility. Then build, then review.
- frontend-design. Pushes against templated, generic-looking UI and forces a self-critique pass.
- ui-ux-pro-max. A searchable local database of UX guidelines, palettes, type pairings and stack-specific advice.
A skills-lock.json pins the versions of the external skills, so every machine and every session gets the same guidance. For client work I have a standing rule that all three run on every change, however small. It sounds like overhead. In practice it's what catches the missing focus state or the button that's 38px tall.
Memory for the things code can't say
Claude Code keeps a small memory across sessions. I use it for facts the repository can't express. The client's real opening hours. That we never claim delivery. That the website must not call the API until the integration phase is agreed. That this client wants every button fully rounded. Each memory is one fact, plus why it matters and how to apply it. Code structure and git history stay out of memory, because the code already records those.
Plans before code for anything big
For large pieces of work (going multi-country, an API contract, a new backend module), the first deliverable is a numbered markdown plan in docs/, not code. The plans are short and specific, and they get reviewed like code. When implementation starts, the AI has a written target, and the plan document becomes the record of what was decided and why.
Verify, don't trust
Nothing is "done" because the model says so. Every change ends with:
pnpm exec tsc --noEmit
pnpm lint
then the page is checked in a real browser: at 375px and at desktop width, with measured values rather than eyeballing. Is the gap between two sections actually 0px? Is the tap target actually 44px? Does the drawer actually return focus to the button that opened it? When something can't be verified, the report says so plainly instead of implying it worked.
Small, specific requests
My requests are short and concrete: "make the builder section full width on mobile without gaps", "the loyalty badge should use the café colour", "remove the rounded edge from the step cards". Because the conventions carry the context, a one-line request is enough. I don't re-explain the stack, the folder layout or the design tokens every time. That's what the contract is for.
What this buys me
- New features land in the right place. The structure tells the AI (and me) where everything goes.
- Changes are cheap. Copy lives in content files, colours in tokens, data behind one function per domain.
- Going live is a small diff. Swapping mock data for a real API or CMS touches the fetch functions, not the UI.
- Reviews are fast. Small files, consistent names, two-line comments.
- The AI gets better, not worse, as the project grows. More code following the same rules means more examples to copy.
Structure is what makes AI-assisted work predictable. Write the rules down, make them boring, and put them where the AI reads them first.