kao-vite-structure
Vite + React + TypeScript + shadcn/ui Project Structure
Stack: Vite · React · TypeScript · shadcn/ui (Radix) · TanStack Query · Zustand · React Hook Form + Zod · React Router v7 · Vitest · Playwright · Biome
This skill defines the canonical folder layout, naming conventions, and architectural rules for the project. Every file you create, move, or modify should follow these patterns.
Project Tree
my-app/
├── public/
├── src/
│ ├── app/ # Runtime wiring only (App, router, providers)
│ ├── assets/
│ ├── components/
│ │ ├── ui/ # shadcn — CLI-managed, never edit manually
│ │ ├── layout/ # App shell + layout route components
│ │ └── common/ # Reusable product-level components (2+ features)
│ ├── config/ # Static config: path.ts, menu.ts, url.ts
│ ├── features/
│ │ └── [feature-name]/ # Self-contained vertical slices
│ ├── hooks/ # Global hooks (used in 2+ features)
│ ├── lib/ # Infrastructure: utils.ts, api-client.ts
│ ├── pages/
│ │ └── [feature-name]/ # Page components + route definitions
│ ├── styles/globals.css
│ ├── types/ # Global types (used in 2+ features)
│ ├── test-setup.ts
│ └── main.tsx
├── tests/
│ ├── e2e/ # Playwright specs, mirrors features/
│ └── fixtures/
├── biome.json
├── components.json
├── playwright.config.ts
├── vite.config.ts
├── tsconfig.json
└── package.json
Naming Conventions
Files — always kebab-case
| Type | Pattern | Example |
|---|---|---|
| Component | kebab-case.tsx |
feature-card.tsx |
| Page | kebab-case-page.tsx |
feature-detail-page.tsx |
| Hook | use-kebab-case.ts |
use-feature-list.ts |
| Utility | kebab-case.ts |
format-status.ts |
| Type | kebab-case.type.ts |
feature.type.ts |
| Schema | kebab-case.schema.ts |
feature-create.schema.ts |
| Store | kebab-case.store.ts |
feature.store.ts |
| API | kebab-case.api.ts |
feature.api.ts |
| Query | kebab-case.query.ts |
feature.query.ts |
| Routes | kebab-case.routes.tsx |
feature.routes.tsx |
| Unit test | source-name.test.tsx |
feature-card.test.tsx |
| E2e test | kebab-case.spec.ts |
feature-create.spec.ts |
Exports
- PascalCase for components:
export function FeatureCard() {} - camelCase for everything else:
export function useFeatureList() {},export function formatStatus() {}
Folders
- Plural for countable collections:
components/,hooks/,pages/,types/,utils/,schemas/,stores/,features/,assets/,styles/ - Singular for uncountable/collective concepts:
api/,lib/,config/,app/
Folder Responsibilities
src/app/ — Runtime Wiring
Only three files: App.tsx, router.tsx, providers.tsx. No business logic, no UI components. Layout components belong in components/layout/, not here.
router.tsximports layouts fromcomponents/layout/and route arrays frompages/— never imports page components directlyproviders.tsxwraps QueryClient, Toaster, ThemeProvider, etc.
src/config/ — Static Configuration
path.ts— single source of truth for all frontend route stringsurl.ts— reads fromimport.meta.env, never hardcodes URLsmenu.ts— imports frompath.ts, never hardcodes route strings- No imports from
features/orcomponents/
src/components/
ui/— CLI-owned shadcn components. Add vianpx shadcn@latest add <name>. Never edit manually.layout/— app shell and layout route components (anything wrapping<Outlet />):app-shell.tsx,auth-layout.tsx,public-layout.tsx,sidebar.tsx,header.tsx,footer.tsxcommon/— reusable product-level components used in 2+ places. If only one feature uses it, keep it in that feature.
src/features/[feature-name]/ — Vertical Slices
Each feature is self-contained:
features/feature-a/
├── components/ # Feature-local UI
├── hooks/ # Feature-local hooks
├── api/ # API calls + TanStack Query definitions
│ ├── feature-a.api.ts
│ └── feature-a.query.ts
├── schemas/ # Zod validation schemas
├── stores/ # Zustand slices
├── types/ # TypeScript types
├── utils/ # Pure helpers
└── index.ts # Public API — only export what pages need
Scale to complexity:
- Small (1–2 components):
components/,index.ts - Medium: +
hooks/,api/,types/ - Large: full structure with
schemas/,stores/,utils/
Complex Components
When a component needs sub-components, local hooks, or local state, give it a subfolder:
components/
└── feature-a-form/
├── index.tsx # Re-exports single public component
├── feature-a-form.tsx # Root orchestrator
├── steps/ # Sub-components
├── components/ # Form-local shared UI
└── hooks/ # Form-local hooks
Callers import transparently — import { FeatureAForm } from "~/features/feature-a" resolves through the subfolder's index.tsx.
API Layer Pattern
// api/feature-a.api.ts — fetch functions
export const featureAApi = {
getAll: () => apiClient.get<FeatureA[]>("/feature-a"),
getById: (id: string) => apiClient.get<FeatureA>(`/feature-a/${id}`),
create: (data: CreateFeatureADto) => apiClient.post("/feature-a", data),
}
// api/feature-a.query.ts — TanStack Query keys + options
export const featureAKeys = {
all: ["feature-a"] as const,
detail: (id: string) => ["feature-a", id] as const,
}
export const featureAQueries = {
list: () => queryOptions({ queryKey: featureAKeys.all, queryFn: () => featureAApi.getAll() }),
detail: (id: string) => queryOptions({ queryKey: featureAKeys.detail(id), queryFn: () => featureAApi.getById(id) }),
}
src/pages/[feature-name]/ — Route-Level Components
Each page folder owns its route definitions:
pages/feature-a/
├── feature-a-page.tsx
├── feature-a-detail-page.tsx
└── feature-a.routes.tsx # RouteObject[] for this feature
- One page file per route, suffixed with
-page - Pages are thin orchestration — no business logic, no direct API calls
- Import from feature
index.tsonly - Route definitions live in
[feature].routes.tsx, never directly inapp/router.tsx
// pages/feature-a/feature-a.routes.tsx
export const featureARoutes: RouteObject[] = [
{ path: "feature-a", element: <FeatureAPage /> },
{ path: "feature-a/:id", element: <FeatureADetailPage /> },
]
src/hooks/, src/types/ — Global Shared
Only promote hooks/types here when used across 2+ features. Feature-specific items stay in the feature.
src/lib/ — Infrastructure
utils.ts— must exportcn()(required by shadcn)api-client.ts— Axios instance reading base URL from~/config/url- No business logic
tests/ — E2e Only
Playwright specs in tests/e2e/, mirroring features/ structure. Vitest unit tests are colocated next to source files in src/.
Import Rules
These rules prevent circular dependencies and keep the architecture clean:
// Pages import from feature index — never deep-import
import { FeatureAList } from "~/features/feature-a" // correct
import { FeatureAList } from "~/features/feature-a/components/feature-a-list" // wrong
// Cross-feature imports always via index
import { useAuth } from "~/features/auth" // correct
import { useAuth } from "~/features/auth/hooks/use-auth" // wrong
// Features import from shared lib, ui, and config
import { cn } from "~/lib/utils"
import { Button } from "~/components/ui/button"
import { API_URLS } from "~/config/url"
Router Composition
app/router.tsx is composition only — it imports layouts from components/layout/ and route arrays from pages/:
import { PublicLayout } from "~/components/layout/public-layout"
import { AuthLayout } from "~/components/layout/auth-layout"
import { authRoutes } from "~/pages/auth/auth.routes"
import { featureARoutes } from "~/pages/feature-a/feature-a.routes"
export const router = createBrowserRouter([
{ element: <PublicLayout />, children: authRoutes },
{ element: <AuthLayout />, children: [...homeRoutes, ...featureARoutes] },
...errorRoutes,
])
Adding a new feature = one import + one spread in the right layout group.
shadcn/ui Rules
- Never manually edit
components/ui/— usenpx shadcn@latest add <component> - Use semantic color tokens only (
bg-background,text-muted-foreground) — never raw Tailwind colors (bg-white,text-gray-500) - Spacing with
gap-*, neverspace-y-* - Equal dimensions with
size-*, neverw-* h-*together - Icons in buttons use
data-iconattribute, no sizing classes on icons:
<Button>
<PlusIcon data-icon="inline-start" />
Create
</Button>
The 7 Rules That Matter Most
- Features are siblings, never parents. Cross-feature imports go through
index.tsonly. - Pages are thin. No logic, no API calls — only composition.
- Each pages folder owns its routes. Route definitions live in
[feature].routes.tsxnext to the pages. router.tsxis composition only. Imports layouts and route arrays — never page components.- Layout components belong in
components/layout/. They are UI, not runtime wiring. components/ui/is CLI-owned. Never edit it manually.- One source of truth per concern. Routes in
config/path.ts, API URLs inconfig/url.ts, menu inconfig/menu.ts.