Skip to content

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

LayerDefaultNotes
Meta-frameworkAstroIslands architecture; React components in client:load islands
UI libraryReact 19-
Component library@dmwd-io/design-systemAlways query Storybook MCP first
StylingTailwind CSS (design system preset)Never bypass design tokens
State — serverTanStack QueryNever store server data in Zustand
State — client/UIZustand (slices)One slice per domain
ValidationZodSingle source of truth
FormsReact Hook Form + Zod resolver-
RoutingTanStack Router (React SPA)File-based for >10 routes
IconsLucide React-
AnimationMotion (Framer Motion v11+)Sparingly
API clientsrc/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.yml

Feature 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 primitives

api.ts example

features/users/api.ts
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.

features/users/api.ts
// 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