Frontend Stack: Astro + React Defaults
The canonical frontend stack for any new app built on the design system. These are committed defaults — do not invent alternatives. The table below is the full picture.
Stack Summary
| Layer | Default | Notes |
|---|---|---|
| Meta-framework | Astro | Islands architecture; React components in client:load islands |
| UI library | React 19 | - |
| Component library | @dmwd-io/design-system | Always query Storybook MCP first |
| Styling | Tailwind CSS (design system preset) | Never bypass design tokens |
| State — server | TanStack Query | Never store server data in Zustand |
| State — client/UI | Zustand (slices) | One slice per domain |
| Validation | Zod | Single source of truth |
| Forms | React Hook Form + Zod resolver | - |
| Routing | TanStack Router (React SPA) | File-based for >10 routes |
| Icons | Lucide React | - |
| Animation | Motion (Framer Motion v11+) | Sparingly |
| API client | src/lib/api-client.ts (project-level) | Never raw fetch in components |
Project Structure
my-app/ src/ components/ # App-specific presentational components features/ # Feature slices: api.ts, types.ts, components/, hooks/ users/ api.ts # TanStack Query hooks + api-client calls types.ts # Zod schemas + TypeScript types UserList.tsx UserCreateDialog.tsx lib/ api-client.ts # Shared fetch wrapper with Zod validation + Idempotency-Key api-types.ts # PaginatedResponse<T>, ApiError, Result<T> providers/ # Provider wrappers (auth, email, ai, storage) pages/ # Astro pages layouts/ # Astro layouts public/ .env.example # Placeholder secrets — commit this; gitignore .env Taskfile.ymlFeature Slice Pattern
Each feature lives in src/features/{feature}/:
features/users/ api.ts # useUsers(), useUser(id), useCreateUser(), useDeleteUser() types.ts # userSchema, User type, CreateUserInput schema UserList.tsx # Composed from design system DataGrid + Pagination UserCreateDialog.tsx # Composed from design system Dialog + form primitivesapi.ts example
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'import { apiGet, apiMutate } from '@/lib/api-client'import { paginatedResponseSchema } from '@/lib/api-types'import { userSchema, type User, type CreateUserInput } from './types'
export function useUsers(page = 1, pageSize = 20) { return useQuery({ queryKey: ['users', page, pageSize], queryFn: () => apiGet( paginatedResponseSchema(userSchema), `/api/users?page=${page}&page_size=${pageSize}`, ), })}
export function useCreateUser() { const queryClient = useQueryClient() return useMutation({ mutationFn: (data: CreateUserInput) => apiMutate(userSchema, 'POST', '/api/users', data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['users'] }) }, })}Dual Mode (Static vs API-Connected)
Each feature slice should support dual mode: local static data for development, API-connected for production. The swap is a one-line change per feature.
// Mode A: static/local-data (development, Storybook)import { MOCK_USERS } from './mock-data'export function useUsers() { return useQuery({ queryKey: ['users'], queryFn: async () => ({ data: MOCK_USERS, pagination: { page: 1, page_size: 20, total: MOCK_USERS.length, total_pages: 1 } }), })}
// Mode B: API-connected (production) — swap the queryFn above with:queryFn: () => apiGet(paginatedResponseSchema(userSchema), '/api/users'),Keep both modes behind the same useUsers() signature so the swap stays a one-line change per feature.
Hard Rules
- Never raw-fetch inside a component — use
api-client.ts - Never store server data in Zustand — use TanStack Query
- Never call vendor SDKs directly — use provider wrappers (
src/lib/providers/) - Never use raw
<input>,<select>,<textarea>— use design system primitives - Always validate API responses with Zod at the api-client boundary