Skip to main content

Software engineering in practice: building a React and Next.js product that lasts

00:10:33:29

Frontend codebases rarely decay because an engineer struggled to write a dropdown component. They decay because the boundaries between components, network requests, and application state dissolve over time.

At first, everything feels lightweight. You write a few components, fetch data inside a useEffect, drop some state into a global store, and ship. Six months later, changing a single backend field breaks four unrelated screens, components re-render unpredictably, and every feature pull request requires an archaeological survey of the entire src/ directory.

Building scalable frontend systems means treating the browser as a distributed client: managing stale caches, handling network boundaries defensively, designing for inevitable failure states, and testing contracts rather than implementation trivia.

Colocate code by business feature, not technical role

The standard framework template—splitting code into components/, hooks/, utils/, and services/—works for introductory tutorials, but it falls apart under organizational scale. When a feature changes, you end up jumping between seven directories across forty files. Worse, it encourages code reuse where no shared domain logic actually exists.

A feature-first structure groups everything required to render, test, and maintain a business capability into a cohesive slice:

text
src/
  features/
    projects/
      api/
        projectQueries.ts      # Fetching, prefetching & TanStack query keys
        projectMutations.ts    # Mutations & cache invalidation logic
      components/
        ProjectCard.tsx        # Feature-specific presentation
        ProjectGrid.tsx
        ProjectStatusBadge.tsx
      model/
        projectTypes.ts        # Typed domain entities & schemas
        projectNormalizer.ts   # Network-to-domain boundary adapter
      screens/
        ProjectsDashboard.tsx  # Full screen composition
      index.ts                 # Explicit public API for other features
  shared/
    components/                # Pure UI design primitives (Buttons, Modals, Inputs)
    lib/                       # Generic utilities (formatting, client configs)
    styles/                    # Global tokens & theme definitions

The index.ts file acts as an architectural boundary. It defines the public surface of the feature; anything not explicitly exported is private to that feature. When another team member refactors internal helpers inside features/projects/, they know with mathematical certainty that external features cannot be broken.

Treat the network boundary as an untrusted contract

External APIs change without warning. Database columns get renamed, backend engineers switch from camelCase to snake_case, and nullable relations return unexpected values.

If your UI components consume raw API payloads directly, every backend schema change ripples across dozens of components. The solution is an Anti-Corruption Layer: normalize and validate responses at the immediate network boundary.

ts
// features/projects/model/projectNormalizer.ts
import { z } from 'zod';

export const RawProjectResponseSchema = z.object({
  id: z.string(),
  project_title: z.string(),
  description: z.string().nullable().optional(),
  lifecycle_state: z.string(),
  created_at: z.string(),
  updated_at: z.string(),
});

export type RawProjectResponse = z.infer<typeof RawProjectResponseSchema>;

export type ProjectDomainModel = {
  id: string;
  title: string;
  summary: string;
  status: 'draft' | 'active' | 'archived';
  lastModifiedDate: Date;
};

export function toProjectDomainModel(raw: unknown): ProjectDomainModel {
  const parsed = RawProjectResponseSchema.parse(raw);

  return {
    id: parsed.id,
    title: parsed.project_title.trim(),
    summary: parsed.description ?? 'No project summary provided.',
    status: normalizeProjectStatus(parsed.lifecycle_state),
    lastModifiedDate: new Date(parsed.updated_at),
  };
}

function normalizeProjectStatus(rawStatus: string): ProjectDomainModel['status'] {
  const clean = rawStatus.toLowerCase();
  if (clean === 'active' || clean === 'archived') return clean;
  return 'draft';
}

Now, your UI components bind strictly to ProjectDomainModel. If the backend changes project_title to display_name tomorrow, you update a single line in your normalizer. Your components, filters, and test suites remain untouched.

The four tiers of application state

One of the costliest architectural mistakes in React is treating all state equally. Shoving network responses into Redux or maintaining complex form state inside React Context creates synchronization nightmares.

Mature applications separate state into four explicit tiers based on lifecycle and ownership:

  1. Server Cache (TanStack Query / SWR / Server Components): Asynchronous data whose source of truth lives in a remote database. Needs caching, background revalidation, stale timeouts, and deduplication.
  2. URL State (Search params & route paths): The most overlooked state in web applications. Filtering, pagination, active tabs, and opened drawer IDs belong in the URL. If a user refreshes the browser or shares a link, the exact visual state should be restored.
  3. Local Interaction State (useState, useReducer): Ephemeral UI data that only matters to a single component or immediate subtree—a hover tooltip, dropdown openness, or input draft before submit.
  4. Shared Client State (Zustand / Redux Toolkit): Truly global, cross-cutting state with no server representation—active workspace preferences, an audio playback queue, or an offline multi-step checkout wizard.
ts
// features/projects/api/projectQueries.ts
import { useQuery } from '@tanstack/react-query';
import { toProjectDomainModel, type ProjectDomainModel } from '../model/projectNormalizer';

export const projectKeys = {
  all: ['projects'] as const,
  lists: () => [...projectKeys.all, 'list'] as const,
  list: (filters: Record<string, string>) => [...projectKeys.lists(), filters] as const,
  detail: (id: string) => [...projectKeys.all, 'detail', id] as const,
};

export function useProjects(filters: Record<string, string>) {
  return useQuery({
    queryKey: projectKeys.list(filters),
    queryFn: async ({ signal }) => {
      const params = new URLSearchParams(filters);
      const res = await fetch(`/api/projects?${params}`, { signal });

      if (!res.ok) {
        throw new Error(`Failed to fetch projects: ${res.status} ${res.statusText}`);
      }

      const json = await res.json();
      return (json.data as unknown[]).map(toProjectDomainModel);
    },
    staleTime: 60 * 1000, // Data stays fresh for 1 minute before background revalidation
  });
}

By keeping query keys structured through a factory (projectKeys), invalidating related queries becomes type-safe and deterministic.

Mutations must manage optimistic feedback and cache invalidation

A mutation is not complete when the PATCH or POST request resolves. The UI must provide instant feedback, handle network errors with rollback guarantees, and revalidate affected caches.

ts
// features/projects/api/projectMutations.ts
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { projectKeys } from './projectQueries';
import type { ProjectDomainModel } from '../model/projectNormalizer';

export function useArchiveProject() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (projectId: string) => {
      const res = await fetch(`/api/projects/${projectId}/archive`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
      });

      if (!res.ok) throw new Error('Archive operation failed');
      return res.json();
    },

    // When mutation is initiated, apply an optimistic update
    onMutate: async (projectId: string) => {
      // Cancel any outgoing refetches so they don't overwrite our optimistic update
      await queryClient.cancelQueries({ queryKey: projectKeys.all });

      // Snapshot previous value for rollback
      const previousProjects = queryClient.getQueryData<ProjectDomainModel[]>(projectKeys.lists());

      // Optimistically update the cache
      if (previousProjects) {
        queryClient.setQueryData<ProjectDomainModel[]>(
          projectKeys.lists(),
          previousProjects.map(p => (p.id === projectId ? { ...p, status: 'archived' } : p))
        );
      }

      return { previousProjects };
    },

    // If the server rejects the mutation, restore previous state
    onError: (_err, _projectId, context) => {
      if (context?.previousProjects) {
        queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
      }
    },

    // Always invalidate after settled to ensure server truth
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: projectKeys.all });
    },
  });
}

This pattern prevents UI lag: the user sees the card change status in under 16ms, while the application safely handles network drops without showing desynchronized data.

Design for the six real UI states before writing the happy path

Tutorial code assumes two states: isLoading ? <Spinner /> : <Data />. In production, binary state handling creates jarring layouts, flickering spinners, and confusing error screens.

Every real-world screen has at least six distinct operational states:

  1. Initial Pending: First visit with zero cache; render skeleton cards matching the eventual layout to prevent Cumulative Layout Shift (CLS).
  2. Background Revalidating: Cached data is already displayed on screen; indicate activity with a subtle progress bar rather than blowing away existing content with a full-screen spinner.
  3. Empty: Request succeeded, but zero records matched; offer actionable next steps (e.g., "Clear active filters" or "Create your first project").
  4. Actionable Error: Differentiate between transient network blips (show a "Try Again" button with exponential backoff) and authorization errors (redirect or show an upgrade prompt).
  5. Partial / Degraded: When a dashboard aggregates five data sources and one times out; render the four successful widgets and an inline recovery badge for the failed one.
  6. Optimistic Pending: An edit is queued in the browser while awaiting network confirmation.
tsx
export function ProjectsDashboardScreen({ filters }: { filters: Record<string, string> }) {
  const { data: projects, isPending, isError, isFetching, error, refetch } = useProjects(filters);

  // 1. Initial Pending: Zero cache available
  if (isPending) {
    return <ProjectsSkeletonGrid count={6} />;
  }

  // 2. Actionable Error
  if (isError) {
    return (
      <DashboardErrorState
        title="Unable to load project workspace"
        description={error.message}
        onRetry={() => refetch()}
      />
    );
  }

  // 3. Empty State
  if (projects.length === 0) {
    return (
      <DashboardEmptyState
        title="No projects match your current filters"
        ctaLabel="Reset all filters"
        onAction={() => resetFilterParams()}
      />
    );
  }

  // 4. Data rendered with subtle background revalidation indicator
  return (
    <section>
      {isFetching && <TopLoadingBar aria-label="Syncing with server..." />}
      <ProjectGrid projects={projects} />
    </section>
  );
}

Notice the crucial difference between isPending and isFetching. In TanStack Query v5, isPending means there is no data at all yet. isFetching indicates any background network activity. Conflating the two replaces helpful screens with disruptive spinners during simple tab switches.

Use Next.js as an explicit request boundary

Pages should act as composition roots, orchestrating authentication and data prefetching before handing execution over to feature components.

In Next.js Pages router, prefetch the query into the server-side QueryClient and pass the serialized cache through dehydrate:

tsx
// pages/projects/index.tsx
import { QueryClient, dehydrate } from '@tanstack/react-query';
import type { GetServerSidePropsContext } from 'next';
import { projectKeys } from 'features/projects/api/projectQueries';
import { projectService } from 'backend/services/projectService';
import { ProjectsDashboardScreen } from 'features/projects/screens/ProjectsDashboard';

export async function getServerSideProps(context: GetServerSidePropsContext) {
  const session = await getSession(context.req);

  if (!session) {
    return {
      redirect: { destination: '/sign-in', permanent: false },
    };
  }

  const queryClient = new QueryClient();

  // Prefetch data on the server during SSR
  await queryClient.prefetchQuery({
    queryKey: projectKeys.list({ ownerId: session.user.id }),
    queryFn: () => projectService.listByOwner(session.user.id),
  });

  return {
    props: {
      dehydratedState: dehydrate(queryClient),
    },
  };
}

export default function ProjectsPage() {
  return <ProjectsDashboardScreen filters={{}} />;
}

In modern Next.js App Router, this responsibility divides even more cleanly: React Server Components (RSC) fetch directly against backend databases or internal microservices on the server without shipping JavaScript bundles to the browser, while interactive client islands mount at the interaction leaves.

Test behavioral contracts, not implementation trivia

The most brittle tests are those that assert whether a component has a particular internal state variable or whether a specific internal hook was invoked. Those tests break the moment you refactor without catching real bugs.

High-leverage frontend tests mock the network layer with Mock Service Worker (MSW) and assert against observable user outcomes:

tsx
import { render, screen, userEvent } from 'test-utils';
import { http, HttpResponse } from 'msw';
import { server } from 'test-mocks/server';
import { ProjectsDashboardScreen } from './ProjectsDashboard';

it('allows a user to recover when the network fails and retry succeeds', async () => {
  const user = userEvent.setup();

  // Mock initial failure
  server.use(
    http.get('/api/projects', () => {
      return HttpResponse.json({ message: 'Internal Server Error' }, { status: 500 });
    }, { once: true })
  );

  render(<ProjectsDashboardScreen filters={{}} />);

  // User sees clear error message
  expect(await screen.findByText(/Unable to load project workspace/i)).toBeInTheDocument();

  // Next request succeeds with project data
  server.use(
    http.get('/api/projects', () => {
      return HttpResponse.json({
        data: [{ id: 'p1', project_title: 'Platform Redesign', lifecycle_state: 'active', updated_at: '2026-08-01' }],
      });
    })
  );

  // Click retry button
  await user.click(screen.getByRole('button', { name: /try again/i }));

  // Interface recovers and renders project card
  expect(await screen.findByRole('heading', { name: /platform redesign/i })).toBeInTheDocument();
});

Because MSW intercepts requests at the network transport layer (fetch), you test the real serialization, schema parsing, normalizers, and React component tree together. You can rewrite your hooks completely without having to rewrite your test suite.

Performance is an architectural discipline

Performance optimization rarely comes from sprinkling useMemo and useCallback indiscriminately across simple components. In fact, shallow memoization often introduces more memory and comparison overhead than it saves.

True frontend performance is governed by macroscopic architectural decisions:

  1. Eliminate Request Waterfalls: Avoid components that render, fetch data, discover a child component, render that child, and trigger another fetch. Prefetch queries at the route boundary or parallelize requests.
  2. Dynamic Imports for Heavy Payloads: Defer rich text editors, 3D Canvas visualizers, or large charting libraries using next/dynamic so initial page chunks remain under 70KB gzip.
  3. Guard Interaction to Next Paint (INP): Defer expensive, non-urgent state recalculations using useDeferredValue or useTransition, allowing the main thread to process keystrokes and button clicks immediately.
  4. Reserve Layout Geometry: Prevent layout shifts by allocating explicit aspect ratios and skeleton dimensions for dynamic images and async cards before data finishes loading.

The standard of craftsmanship

Reliable software engineering is rarely glamorous. It doesn't rely on hype cycles or rewriting your application every time a new library trends on social media.

It shows up in the quiet, disciplined decisions:

  • Boundaries are explicitly defined and defensively typed.
  • Stale data rules and cache invalidation are predictable.
  • Failure states are planned, designed, and graceful.
  • Tests describe genuine user workflows.
  • A new engineer can join the team and deploy a feature on their first week without breaking distant parts of the product.

React and Next.js offer exceptional primitives. The engineering craft is knowing where each primitive belongs and having the discipline to keep them in their proper place.