Most React tutorials get you to "it works." This guide gets you to "it scales."
There's a wide gap between spinning up a Vite project and building something that stays maintainable, performs well under load, and doesn't become a security liability six months down the road. This is how senior frontend developers think when starting from scratch — covering architecture, routing, state, performance, security, and testing from day one.
1. Start with the Right Stack (2026 Edition)
Don't overthink the tooling. Here's a battle-tested baseline:
| Tool | Why |
|---|---|
| React + TypeScript | Type safety prevents entire categories of runtime bugs |
| Vite | Blazing-fast dev server and optimized production builds |
| ESLint + Prettier | Enforce consistency across the whole team |
| Vitest + Testing Library | Fast, modern testing with great React support |
npm create vite@latest my-app -- --template react-ts
TypeScript in particular pays compounding dividends — your future self (and teammates) will thank you when refactoring a component three months later.
2. Project Structure: Get This Right Early
This is where most apps go wrong. The instinct is to sort by file type:
❌ components/
❌ utils/
❌ services/
This feels clean at first. By the time you have 40 components, it's a disaster.
Use a feature-based structure instead:
src/
├─ app/ # App shell, providers, routing
├─ features/
│ ├─ auth/
│ │ ├─ api.ts
│ │ ├─ hooks.ts
│ │ ├─ components/
│ │ └─ tests/
│ └─ dashboard/
├─ shared/
│ ├─ ui/ # Reusable design system components
│ ├─ hooks/
│ └─ utils/
├─ services/ # API clients, third-party integrations
└─ styles/
Each feature owns its API calls, hooks, components, and tests. This makes features easy to delete, refactor, or hand off to another team member without touching unrelated code.
3. Routing from Day One
A common oversight in "from scratch" guides: routing. Every real app needs it.
Use TanStack Router (type-safe, great DX) or React Router v7 depending on your preference:
npm install @tanstack/react-router
// app/router.tsx
import { createBrowserRouter } from '@tanstack/react-router'
const router = createBrowserRouter({
routeTree: rootRoute.addChildren([
indexRoute,
dashboardRoute,
authRoute,
])
})
Set this up on day one. Retrofitting routing into an app that wasn't designed for it is painful.
4. State Management: Match the Tool to the Problem
The biggest mistake here is reaching for a global state library for everything. Think in layers:
| State Type | Tool |
|---|---|
| Local UI state (toggle, input) | useState |
| Shared UI state across siblings | React Context |
| Server data (fetching, caching) | TanStack Query |
| Complex global app state | Zustand |
Server state is the one that catches most teams off guard. TanStack Query handles caching, background refetching, and loading/error states out of the box:
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: fetchUsers,
})
That's less boilerplate than useEffect + useState, with far fewer bugs.
5. Build Reusable Components Without Over-Abstracting
The rule: reuse patterns, not components blindly.
Keep UI components dumb — they receive props and render. Keep logic in custom hooks:
// ❌ Fat component doing too much
function UserCard() {
const [user, setUser] = useState(null)
useEffect(() => { fetchUser().then(setUser) }, [])
return <div>{user?.name}</div>
}
// ✅ Separated concerns
function useUser(id: string) {
return useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
}
function UserCard({ id }: { id: string }) {
const { data: user } = useUser(id)
return <div>{user?.name}</div>
}
Compose, don't inherit. A <Button variant="primary" size="md" /> that accepts children is more flexible than ten button variants.
6. Environment Variables and Config
This gets skipped in most guides. Don't skip it.
# .env.local (never commit this)
VITE_API_BASE_URL=https://api.yourapp.com
VITE_FEATURE_FLAG_NEW_DASHBOARD=true
// services/config.ts
export const config = {
apiBaseUrl: import.meta.env.VITE_API_BASE_URL,
features: {
newDashboard: import.meta.env.VITE_FEATURE_FLAG_NEW_DASHBOARD === 'true',
},
}
Centralizing config in one place means you change a value once, not hunt through 20 files. Add .env.local to .gitignore from the start.
7. Performance: Build Fast by Default
The three principles:
- Render less — avoid unnecessary re-renders
- Load less — split your bundle
- Block less — don't hold up the main thread
Code splitting is your highest-leverage move:
const Dashboard = lazy(() => import('./features/dashboard/Dashboard'))
// Wrap in Suspense
<Suspense fallback={<Spinner />}>
<Dashboard />
</Suspense>
Memoization helps, but only when you've measured first. Don't sprinkle useMemo and useCallback everywhere — use React DevTools Profiler to find actual bottlenecks, then fix them.
For long lists (100+ items), use react-window or TanStack Virtual to avoid rendering off-screen DOM nodes.
8. Responsive Design: Think Component-Level, Not Page-Level
Don't tack responsiveness on at the end. Design mobile-first from the start:
/* Adapts from 1 column on mobile to multi-column on wider screens */
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}
The key insight is to make each component handle its own responsive behavior instead of writing breakpoint overrides at the page level. Components become portable and predictable.
9. Security: The Frontend Is Not Trustless
Frontend code is visible to anyone. Treat it accordingly.
XSS: Never use dangerouslySetInnerHTML with unsanitized user input. If you must, use a library like dompurify.
Auth tokens: Don't store JWTs in localStorage. It's accessible to any JavaScript on the page, which means XSS vulnerabilities can steal tokens.
// ❌ Vulnerable to XSS
localStorage.setItem('token', jwt)
// ✅ Use httpOnly cookies set by the server
// The browser sends them automatically; JS can't read them
CSRF: Use SameSite=Strict or SameSite=Lax cookies and add CSRF tokens for sensitive mutations.
Dependencies: Run npm audit regularly and pin your versions. A compromised transitive dependency is a real attack vector.
10. Testing: Make It Cheap, Not Optional
The frontend testing pyramid:
E2E (Playwright) — a few critical flows
Integration — key feature interactions
Unit (Vitest) — utilities, hooks, pure logic
The golden rule: test what users see, not how your code works internally.
// ❌ Implementation detail test
expect(component.state.isLoggedIn).toBe(true)
// ✅ Behavior test
render(<Login />)
await userEvent.type(screen.getByLabelText('Email'), 'user@test.com')
await userEvent.click(screen.getByRole('button', { name: 'Log in' }))
expect(screen.getByText('Welcome back')).toBeInTheDocument()
This kind of test survives refactors. If you rename internal state or restructure a component, behavior tests don't break.
11. CI and Quality Gates
None of this matters if code that breaks these rules can ship. Automate the enforcement.
A minimal GitHub Actions workflow:
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
quality:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- run: npm run lint
- run: npm run test -- --coverage
- run: npm run build
Also add pre-commit hooks via husky + lint-staged so issues are caught before they even reach CI:
npm install -D husky lint-staged
Putting It All Together
A scalable React app isn't about any single decision — it's about the sum of good defaults made early:
- Feature-driven structure that grows without chaos
- Routing configured from day one
- State tools matched to the actual problem
- Performance treated as a requirement, not an afterthought
- Security baked in, not bolted on
- Tests that survive refactors
- CI that enforces all of the above
None of these are difficult individually. The discipline is making them the default rather than something you add "when the app gets bigger." By the time the app is bigger, the patterns are already set — for better or worse.
Start boring. Stay boring. Ship confidently.
Top comments (0)