The first hour of a project decides how the next hundred feel. If the conventions are in place before the first feature, every feature after it lands in a predictable place, and that's true whether I write it or Claude Code does. If they aren't, you end up refactoring structure while you are trying to ship.
This is the setup I use for every new web project, in the order I do it. Following it takes about an hour. It works for a marketing site, a store or an admin portal, because all three need the same things: typed data, editable content, consistent files and a way to swap mocks for a real backend later.
1. Create the app
I start from the official TanStack Start template with pnpm:
pnpm create @tanstack/start@latest my-app
cd my-app
pnpm install
The CLI's options change from release to release, so check the TanStack docs for the current flags. I choose Tailwind, ESLint and Prettier, plus the TanStack Query integration.
The result is TanStack Start on Vite, React 19, file-based routing and server rendering. Then I remove everything the template shipped as a demo: the sample routes, the demo data and the placeholder styles. A clean setup starts with nothing you didn't choose.
If the host needs native build scripts approved (esbuild, lightningcss, sharp), approve them in the pnpm config now, and write down in the README that pnpm rebuild fixes ERR_PNPM_IGNORED_BUILDS. It will come up.
2. One import alias
Every file under src/ is imported through #/. It goes in two places:
// package.json
"imports": {
"#/*": "./src/*"
}
// tsconfig.json → compilerOptions
"paths": {
"#/*": ["./src/*"]
}
From here on, imports look like import { cn } from '#/lib/utils'. Relative paths that climb (../../) are not allowed.
3. Lint, format and typecheck
// eslint.config.js
import { tanstackConfig } from '@tanstack/eslint-config'
export default [
...tanstackConfig,
{ ignores: ['eslint.config.js', 'prettier.config.js'] },
]
The scripts in package.json:
"scripts": {
"dev": "vite dev --port 3000",
"build": "vite build",
"generate-routes": "tsr generate",
"lint": "eslint",
"format": "prettier --write . && eslint --fix",
"check": "prettier --check ."
}
and pnpm exec tsc --noEmit for types. Turn on noUnusedLocals and noUnusedParameters in tsconfig.json. Dead imports are the first sign of code nobody reads.
These three commands (tsc, lint, format) are the gate every change passes through, mine or the AI's.
4. Lay out the folders before writing code
Create the empty structure first, so the first file has somewhere obvious to go:
src/
├── routes/ file-based routes
├── components/
│ ├── common/ shared across pages
│ ├── layout/ header, footer, app shell
│ └── ui/ shadcn primitives
├── content/ static page copy, typed
├── data/ dynamic records, typed
├── hooks/
│ ├── queries/ reads, grouped by domain
│ └── mutations/ writes, grouped by domain
├── lib/ utils, errors, http-client, seo
├── stores/ client state slices
├── constants/ cache times, site identity, feature flags
├── integrations/ provider wiring
└── types/ domain models
Page-specific components get a folder named after the page (components/home/, components/menu/, components/checkout/).
5. Write the house rules down
These rules apply from the first file:
- Kebab-case, lowercase file names.
menu-item-row.tsx,format-price.ts,http-client.ts. - Domain suffixes on data hooks.
menu.query.tsfor reads,orders.mutation.tsfor writes. - One component per file. Sub-components get their own file. Behavioural hooks are one per file too (
use-scrolled.ts). - Comments are two lines at most, and explain why rather than what. Hooks and services get a short doc header with a one-line purpose and the author.
#/imports only.- Nothing user-visible is hardcoded in a component.
The last rule shapes most of the rest of the setup.
6. Types first
src/types/index.ts is the single source of truth for every shape in the app. Before building a page, I write the interfaces for its data and its content:
export interface Cta {
label: string
href: string
}
export interface MenuItem {
id: string
slug: string
name: string
price: number
category: string
available: boolean
image?: string
}
Components, data files, content files and hooks all import from here. When a shape changes, the type checker lists every place that needs to follow.
7. The content seam
Page copy (headings, hero text, button labels, FAQ entries, nav links) lives in src/content/, one typed module per page or area:
// src/content/home.ts
import type { HomeContent } from '#/types'
export const homeContent: HomeContent = {
hero: {
title: 'Coffee worth crossing the road for.',
body: 'Sit in, or order ahead and pick up at the counter.',
cta: { label: 'See the menu', href: '/menu' },
},
steps: [
{ id: 'order', order: 1, title: 'Order ahead', icon: 'coffee' },
{ id: 'collect', order: 2, title: 'Collect at the counter', icon: 'gift' },
],
}
The rules that make this ready for a CMS later:
- Plain serialisable values only. No JSX, functions or imported components.
- Icons are string keys (
'coffee') mapped to components in oneicon.tsx. - Every record has a stable
idorslug, and ordered lists carry anorder. - One interface per block type, reused wherever the block appears.
Static copy can be imported straight from src/content/. When a CMS arrives, these modules become queries, and the components don't change.
8. The data seam
Dynamic records (products, reviews, orders, banners) live in src/data/ as typed arrays with small accessor functions:
// src/data/menu.ts
import type { MenuItem } from '#/types'
const MENU: Array<MenuItem> = [/* … */]
export const getMenu = (category?: string) =>
category ? MENU.filter((m) => m.category === category) : MENU
export const getMenuItemBySlug = (slug: string) => MENU.find((m) => m.slug === slug)
Components never import these directly. They go through hooks, which is where the backend will eventually plug in.
9. The plumbing in lib/
Four small modules, written once and reused everywhere:
lib/utils.ts holds cn() for merging Tailwind classes. shadcn adds this for you.
lib/http-client.ts wraps fetch with a timeout (15 seconds by default), retries with backoff, and typed TimeoutError / NetworkError values. Nothing calls it yet, but it's ready.
lib/errors.ts turns any thrown value into a message that is safe to show a user, and wraps async functions so every failure passes through it:
export function sanitizeError(error: unknown, fallback: string): Error {
const err = error instanceof Error ? error : new Error(fallback)
if (isTimeoutError(err)) err.message = 'The request took too long. Please try again.'
else if (isNetworkError(err)) err.message = 'Unable to reach the server. Please try again later.'
else err.message = (err as { data?: { message?: string } }).data?.message || err.message || fallback
return err
}
export const withErrorHandling =
<T extends (...args: Array<any>) => Promise<any>>(fn: T, fallback: string) =>
async (...input: Parameters<T>): Promise<Awaited<ReturnType<T>>> => {
try {
return await fn(...input)
} catch (error) {
throw sanitizeError(error, fallback)
}
}
lib/seo.ts is one seo() helper that returns a route's head: title, description, canonical URL, Open Graph, Twitter card and robots. It reads the defaults from constants/site.ts.
10. Constants
// src/constants/index.ts
export const DEFAULT_STALE_TIME = 1000 * 60 * 5 // 5 minutes
export const DEFAULT_GC_TIME = 1000 * 60 * 30 // 30 minutes
// src/constants/site.ts
export const SITE = {
name: 'My App',
defaultTitle: 'My App — what it does, in a line',
description: '…',
url: 'https://www.example.com', // set the real domain before launch
ogImage: '/og/default.jpg',
locale: 'en_GB',
}
Feature flags go in constants/features.ts, read from environment variables, so a half-finished section can ship switched off.
11. Wire the providers once
TanStack Start's Query integration does more than it looks:
integrations/tanstack-query/root-provider.tsxcreates theQueryClientingetContext().router.tsxcallssetupRouterSsrQueryIntegration({ router, queryClient }). This already wraps the app inQueryClientProvider, so don't add a second one.routes/__root.tsxis the document shell:<html>,<head>, the site-wide SEO defaults, devtools and<Scripts />.
I write that "don't add a second provider" line into CLAUDE.md on day one. It's the mistake everyone makes once.
12. Build one domain end to end
Before any real feature, I build one complete vertical slice to prove the seams work. It has five parts: type, data, query hook, route loader and component.
// src/hooks/queries/menu.query.ts
/**
* Reads the menu from the mock-data seam.
* @author Joseph Nartey
* @github devjoemedia
*/
export const menuKeys = {
all: ['menu'] as const,
list: (category?: string) => [...menuKeys.all, 'list', category] as const,
}
const fetchMenu = withErrorHandling(async (category?: string) => {
await new Promise((r) => setTimeout(r, 300)) // simulate latency
return getMenu(category) // ← swap for httpClient.get('/menu')
}, 'Failed to load the menu')
export const menuQueryOptions = (category?: string) =>
queryOptions({
queryKey: menuKeys.list(category),
queryFn: () => fetchMenu(category),
staleTime: DEFAULT_STALE_TIME,
gcTime: DEFAULT_GC_TIME,
})
export const useMenuQuery = (category?: string) => useQuery(menuQueryOptions(category))
// src/routes/menu.tsx
export const Route = createFileRoute('/menu')({
loader: ({ context }) => context.queryClient.ensureQueryData(menuQueryOptions()),
head: () => seo({ title: 'Menu', description: 'Everything we pour and grill.', path: '/menu' }),
component: MenuPage,
})
The component calls useMenuQuery(). Its data is already in the cache from the loader, so the page server-renders with content and doesn't flash on load. The simulated latency means the loading, empty and error states have to exist from the start.
Every later domain copies this slice. Going live means changing the body of fetchMenu.
13. Design tokens before components
Tailwind v4 reads the theme from CSS. I define the brand as variables and map them onto shadcn's token names:
:root {
--background: #ffffff;
--foreground: #2a160d;
--primary: #d2461a;
--primary-foreground: #ffffff;
--radius: 0.5rem;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
Components use bg-primary and text-foreground, never a hex value. Load the fonts once and assign them roles (display, body). When a product has several apps, one shared DESIGN-SYSTEM.md at the repository root holds the token tables, and each app's own file points to it.
Then add primitives as you need them:
pnpm dlx shadcn@latest add button dialog
14. Write CLAUDE.md
This is the file that makes the setup work with an AI. Claude Code reads it at the start of every session, so everything above goes into it as short, firm rules. My template:
# CLAUDE.md — <Project>
<Two sentences: what the product is and who it's for.> Core domains: <list>.
## 1. Stack
## 2. Commands
## 3. File & code conventions (required)
## 4. Folder structure
## 5. Providers & app wiring
## 6. Data & content — the CMS-ready seam
## 7. Styling & design
## 8. SEO (required on every page)
## 9. Skills
## 10. Authorship
## 11. Definition of done
- [ ] `pnpm exec tsc --noEmit` and `pnpm lint` pass
- [ ] Renders correctly on mobile and desktop
- [ ] Kebab-case files, one component per file, comments ≤ 2 lines
- [ ] Data flows through a hook + `src/data` accessor
- [ ] No hardcoded copy or data in components
- [ ] The route sets `seo()` metadata
- [ ] Loading, empty and error states handled
State rules as rules. "One component per file, always" works. "Try to keep files small" doesn't.
15. Install the skills
Frontend projects get three agent skills in .claude/skills/: an engineering workflow (plan the states, structure, responsive behaviour and accessibility before building), a design-direction skill that pushes back on generic-looking UI, and a searchable UX reference. A skills-lock.json pins the external ones to a version and hash, so every machine and session runs the same guidance. CLAUDE.md says when to use each one.
Add a .claude/launch.json naming the dev server, so the AI can start the app and check its own changes in a browser instead of asking you to look.
16. Plans live in docs/
For anything bigger than a feature (a backend module, a multi-country rollout, an API contract), the first commit is a numbered plan in docs/ (00-setup.md, 01-auth.md…), not code. Plans are short, specific and reviewed. They're how decisions survive after the chat window closes.
The checklist
When the setup is done, a new project has:
- TanStack Start + Query + Router, pnpm, demo code removed
-
#/alias inpackage.jsonandtsconfig.json - ESLint, Prettier and
tsc --noEmitscripts, unused locals as errors - The full
src/folder structure -
types/,content/anddata/with one domain in each -
lib/utils,lib/errors,lib/http-client,lib/seo -
constants/with cache times, site identity and feature flags - One vertical slice: type → data → query hook → loader → component
- Brand tokens in
styles.css, fonts loaded, shadcn configured -
CLAUDE.mdwith conventions and a definition of done - Skills installed and pinned,
launch.jsonfor the dev server -
docs/for plans
None of it is clever. It's the same, every time, and that's why it works.