How would you build a full React application from scratch covering state management, forms, and component architecture?
End-to-end recipe: Vite or Next.js scaffold; TypeScript strict; ESLint+Prettier; Tailwind+Radix or design system; React Query for server state; Zustand for client state; React Hook Form + Zod for forms; React Router or Next router; Sentry+web-vitals for observability; Vitest+Testing Library+MSW for tests; CI with type-check+test+bundle budget. Feature folders; codeowners; ADRs.
End-to-end recipe for a credible production app.
Scaffold
npx create-next-app@latest my-app --typescript --tailwind --eslint --app
# or
npm create vite@latest my-app -- --template react-tsPick Next.js for SSR/SEO/RSC; Vite for pure CSR / library work.
TS config
{
"compilerOptions": {
"strict": true,
"noUnusedLocals": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "Bundler",
"esModuleInterop": true,
"skipLibCheck": true
}
}Stack
| Concern | Tool |
|---|---|
| Styling | Tailwind + tokens via CSS vars |
| UI primitives | Radix / Headless UI + CVA variants |
| Server state | React Query (or RTK Query) |
| Client state | Zustand |
| Forms | React Hook Form + Zod |
| Validation | Zod schemas shared client/server |
| Routing | Next App Router / React Router |
| Auth | Auth provider + httpOnly cookies |
| Testing | Vitest + Testing Library + MSW + Playwright |
| Lint/format | ESLint + Prettier (or Biome) |
| Observability | Sentry + web-vitals |
| Bundle budget | size-limit |
Folder structure
src/
├ app/ (Next routes or App.tsx)
├ features/
│ ├ cart/
│ ├ checkout/
│ └ profile/
├ shared/
│ ├ ui/ (design system primitives)
│ ├ hooks/
│ └ utils/
├ lib/ (http, auth, storage)
└ types/Component example
// features/profile/ProfileForm.tsx
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const Schema = z.object({
name: z.string().min(1),
email: z.string().email(),
});
type FormData = z.infer<typeof Schema>;
export function ProfileForm({ defaults, onSubmit }: { defaults: FormData; onSubmit: (data: FormData) => Promise<void> }) {
const { register, handleSubmit, formState: { errors, isSubmitting } } = useForm<FormData>({
resolver: zodResolver(Schema),
defaultValues: defaults,
});
return (
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<label>
Name
<input {...register('name')} className="..." />
{errors.name && <span>{errors.name.message}</span>}
</label>
<label>
Email
<input {...register('email')} type="email" className="..." />
{errors.email && <span>{errors.email.message}</span>}
</label>
<button type="submit" disabled={isSubmitting}>Save</button>
</form>
);
}Data layer
const useProfile = (id: string) => useQuery({
queryKey: ['profile', id],
queryFn: () => api.get(`/users/${id}`).then((r) => r.json()),
});
const useUpdateProfile = () => useMutation({
mutationFn: (data: ProfileData) => api.patch('/profile', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profile'] }),
});Client state
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export const useCart = create(persist((set) => ({
items: [],
add: (item) => set((s) => ({ items: [...s.items, item] })),
}), { name: 'cart' }));Testing
test('saves profile', async () => {
const onSubmit = vi.fn();
render(<ProfileForm defaults={{ name: 'A', email: 'a@b.c' }} onSubmit={onSubmit} />);
await userEvent.clear(screen.getByLabelText(/name/i));
await userEvent.type(screen.getByLabelText(/name/i), 'B');
await userEvent.click(screen.getByRole('button', { name: /save/i }));
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({ name: 'B' }));
});CI
- Type check.
- Lint.
- Unit + integration tests.
- Build.
- size-limit per route.
- Playwright smoke on critical paths.
- Visual regression for design system.
Observability
import * as Sentry from '@sentry/react';
import { onLCP, onINP, onCLS } from 'web-vitals';
Sentry.init({ dsn: process.env.NEXT_PUBLIC_SENTRY_DSN });
onLCP(({ value }) => analytics.track('LCP', { value }));
onINP(({ value }) => analytics.track('INP', { value }));
onCLS(({ value }) => analytics.track('CLS', { value }));Conventions
- ADRs in
docs/adr/. - CODEOWNERS per feature.
- Commit conventions (Conventional Commits).
- Feature flags via LaunchDarkly / GrowthBook / similar.
Interview framing
"Next.js (or Vite) with TypeScript strict. Tailwind + Radix primitives + CVA variants for UI. React Query for server state, Zustand for client state, React Hook Form + Zod for forms (with shared schemas for server validation). Feature folders with strict dependency direction. Vitest + Testing Library + MSW + Playwright for tests. CI gates: type check, lint, tests, build, bundle budget per route, visual regression. Sentry + web-vitals for observability. ADRs and CODEOWNERS for governance. The recipe matters less than the discipline — measurement, ownership, gates."
Follow-up questions
- •How would you handle auth in this stack?
- •What does your CI look like?
- •How do you organize features at scale?
Common mistakes
- •Skipping Zod / shared schemas.
- •No CI bundle budget.
- •Mixing server state into client store.
- •Type checking only locally.
Performance considerations
- •Stack defaults are good; budgets in CI prevent regression.
Edge cases
- •SSR + Zustand store-per-request.
- •Auth with edge runtime.
- •Form schemas shared with server validators.
Real-world examples
- •Vercel templates, T3 stack, Next.js examples.