DEV Community

Cover image for How to Build a React App from Scratch That Doesn't Fall Apart in 6 Months - Scalable, Performant, Secure, and Testable
Anisubhra Sarkar (Ani)
Anisubhra Sarkar (Ani)

Posted on

How to Build a React App from Scratch That Doesn't Fall Apart in 6 Months - Scalable, Performant, Secure, and Testable

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
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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/
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// app/router.tsx
import { createBrowserRouter } from '@tanstack/react-router'

const router = createBrowserRouter({
  routeTree: rootRoute.addChildren([
    indexRoute,
    dashboardRoute,
    authRoute,
  ])
})
Enter fullscreen mode Exit fullscreen mode

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,
})
Enter fullscreen mode Exit fullscreen mode

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>
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
// 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',
  },
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Also add pre-commit hooks via husky + lint-staged so issues are caught before they even reach CI:

npm install -D husky lint-staged
Enter fullscreen mode Exit fullscreen mode

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)