ReactJavascriptAITypescript

Hardening an AI-Generated React App for Production

A six-dimension audit that turns an AI-generated React prototype into a shippable app, covering data, errors, types, performance, a11y, and observability.

Last updated: 26 May 2026

A bare React component tree clad with labelled armour plates for types, errors, queries, accessibility, performance, and observability.

AI-generated React code passes the demo and fails the on-call. The six-dimension audit pass, covering data, errors, types, performance, accessibility, and observability, is what turns a vibe-coded prototype into something you can ship. This article walks each dimension with the AI-generated starting point, the failure mode under real traffic, and the production-grade rewrite. Version targets: React 19.2, TanStack Query 5.100, Redux Toolkit 2.x, TypeScript 5.9.

TL;DR, the six-dimension audit pass

Hardening AI-generated React code is a methodical pass across six dimensions where the model defaults to demo-grade patterns: replace raw useEffect fetches with TanStack Query or RTK Query, wrap routes in error boundaries that log and recover, turn on strict TypeScript with noUncheckedIndexedAccess, fix memoisation that the model either skipped or over-applied, restore focus management and semantic landmarks the AI omitted, and wire Sentry plus PostHog at the auth, payment, and top failure routes. The pass targets React 19.2, TanStack Query 5.100, Redux Toolkit 2.x, TypeScript 5.9, @sentry/react 10.x, and posthog-js 1.375+ with @posthog/react 1.9.x. Run it once per file before the prototype touches a real user.

The six dimensions, one verb each:

  1. Data layer, replace.
  2. Error handling, contain.
  3. Type safety, tighten.
  4. Performance, measure.
  5. Accessibility, restore.
  6. Observability, instrument.

The starting point, an AI-generated React app

Most AI-generated React apps ship with three failure modes baked in: a raw fetch inside useEffect, a console.error in the catch branch, and implicit any wherever the model could not infer a shape. The component renders fine on the happy path, the screenshots look right in the PR, and the app collapses the first time the API returns a 500.

Here is the kind of dashboard widget the model produces when asked for "a list of recent orders with a refresh button". Notice how many of the six dimensions it already fails.

src/features/orders/RecentOrders.jsx

import { useEffect, useState } from 'react'

export default function RecentOrders() {
  const [orders, setOrders] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    setLoading(true)
    fetch('https://api.example.com/orders/recent')
      .then(res => res.json())
      .then(data => {
        setOrders(data)
        setLoading(false)
      })
      .catch(err => {
        console.error(err)
        setLoading(false)
      })
  }, [])

  if (loading) return <div>Loading...</div>

  return (
    <div>
      <h2>Recent orders</h2>
      <ul>
        {orders.map(o => (
          <li key={o.id}>
            {o.customer} - {o.total}
          </li>
        ))}
      </ul>
    </div>
  )
}

That snippet is the reference point for the rest of the article. Every dimension fixes one specific thing this component does wrong. By the end, the code looks nothing like the original but the user-visible output is identical, which is the test of a good hardening pass.

Dimension 1, the data layer

The data layer is where AI-generated React collapses first. Models default to fetch inside useEffect because that pattern dominates the training set, and it brings four production failures with it: a double fetch under React 19 StrictMode, no retry on transient errors, no cache reuse, and a permanent loading flicker on navigation back. The fix is to stop using useEffect for data and delegate caching, retries, and request deduplication to a server-state library. For most teams in 2026 that is TanStack Query 5.x; for teams already running Redux Toolkit, RTK Query 2.x. The trade-offs sit in RTK Query vs TanStack Query, and a deeper read on TanStack Query as an async state manager lives in this companion piece.

Replacing useEffect + fetch with TanStack Query or RTK Query

The starting point above breaks the moment the user refocuses the tab. Nothing refetches, the data on screen is stale, and there is no signal to the user that the list has not been updated since the morning. Push the same component into a flaky network and it stays in Loading... forever, because the catch branch swallows the failure silently.

The TanStack Query rewrite collapses both problems and shrinks the component.

src/features/orders/RecentOrders.tsx

import { useQuery } from '@tanstack/react-query'
import { ordersApi } from '@/api/orders'
import type { Order } from '@/api/orders'

export default function RecentOrders() {
  const { data, isLoading, isError, error, refetch } = useQuery<Order[], Error>({
    queryKey: ['orders', 'recent'],
    queryFn: ordersApi.recent,
    staleTime: 30_000,
    retry: 2,
  })

  if (isLoading) return <div role="status">Loading recent orders</div>
  if (isError) {
    return (
      <div role="alert">
        <p>Could not load recent orders. {error.message}</p>
        <button type="button" onClick={() => refetch()}>
          Try again
        </button>
      </div>
    )
  }

  return (
    <section aria-labelledby="recent-orders-heading">
      <h2 id="recent-orders-heading">Recent orders</h2>
      <ul>
        {data?.map(o => (
          <li key={o.id}>
            {o.customer}, {o.total}
          </li>
        ))}
      </ul>
    </section>
  )
}

We get three behaviours for free that the original lacked. The query refetches when the tab regains focus, retries twice on transient failures, and shares its cache with any other component that asks for the same queryKey. The component code is now purely about rendering states the user can see, and the failure mode is a recoverable UI with a retry button rather than a permanent spinner. Pair this with a single QueryClientProvider at the app root and staleTime tuned to the data's volatility and most "the dashboard is stale" tickets disappear.

The same idea expressed in RTK Query if you are already invested in Redux Toolkit.

src/api/ordersApi.ts

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import type { Order } from '@/api/orders'

export const ordersApi = createApi({
  reducerPath: 'ordersApi',
  baseQuery: fetchBaseQuery({
    baseUrl: import.meta.env.VITE_API_BASE_URL,
  }),
  tagTypes: ['Orders'],
  endpoints: builder => ({
    getRecentOrders: builder.query<Order[], void>({
      query: () => 'orders/recent',
      providesTags: ['Orders'],
    }),
  }),
})

export const { useGetRecentOrdersQuery } = ordersApi

The difference between the two libraries is mostly bundle size, the shape of cache invalidation, and whether you already need Redux for client state. For teams who only need server state, TanStack Query is the lighter choice. For teams who already run Redux for cross-cutting client state, RTK Query rides on the store you already have. The client-state question itself is its own decision; see Zustand vs Redux Toolkit for the trade-off there.

Centralising the API client and base URL

The AI-generated snippet hard-coded https://api.example.com/orders/recent. That single decision blocks every environment promotion you will ever do. Staging cannot point at staging, the e2e tests cannot point at a local mock server, and the on-call engineer cannot flip the base URL when the production API moves behind a new edge proxy. Centralise the client and the base URL before any feature work continues.

src/api/client.ts

import axios, { AxiosError, AxiosInstance } from 'axios'

const baseURL = import.meta.env.VITE_API_BASE_URL

if (!baseURL) {
  throw new Error('VITE_API_BASE_URL is not set')
}

export const apiClient: AxiosInstance = axios.create({
  baseURL,
  timeout: 10_000,
  headers: { 'Content-Type': 'application/json' },
})

apiClient.interceptors.response.use(
  response => response,
  (error: AxiosError) => {
    if (error.response?.status === 401) {
      window.dispatchEvent(new CustomEvent('auth:unauthorised'))
    }
    return Promise.reject(error)
  },
)

src/api/orders.ts

import { apiClient } from '@/api/client'

export interface Order {
  id: string
  customer: string
  total: string
}

export const ordersApi = {
  recent: async (): Promise<Order[]> => {
    const { data } = await apiClient.get<Order[]>('/orders/recent')
    return data
  },
}

The wrapper enforces three rules in one place: a missing base URL fails fast at boot rather than 500ms into the first render, every request has a sensible timeout so a hung backend does not freeze the UI, and a 401 emits a single event that the auth slice can subscribe to. Every new endpoint inherits all three for free. The wrapper is also the seam where Sentry breadcrumbs and request IDs attach later, which is why getting it in early pays off.

Dimension 2, error handling and resilience

The AI-generated React app handles errors the way the model saw most demo apps handle them: a try/catch that calls console.error, a flag that flips a generic spinner off, and nothing else. At 2am when the orders API returns 500, the user sees a blank screen, the on-call engineer sees no telemetry, and the front-end team gets a Slack message that reads "the app is broken". The fix is layered: error boundaries at the route and feature level, a single handler that fans an error out to the user, the log, and the reporter, and a deliberate choice about which errors recover automatically.

Error boundaries at the route and feature level

React 19 still ships class-based error boundaries as the official primitive, and react-error-boundary is the production wrapper most teams reach for. Two layers matter: a route boundary that catches anything inside a page and shows a recovery UI, and a feature boundary that isolates a single widget so one failing chart does not blank the whole dashboard.

src/app/RouteErrorBoundary.tsx

import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import * as Sentry from '@sentry/react'
import { useNavigate } from 'react-router-dom'

function RouteFallback({ error, resetErrorBoundary }: FallbackProps) {
  const navigate = useNavigate()
  return (
    <div role="alert" className="route-error">
      <h1>Something went wrong on this page</h1>
      <p>{error.message}</p>
      <button type="button" onClick={resetErrorBoundary}>
        Reload this page
      </button>
      <button type="button" onClick={() => navigate('/')}>
        Go home
      </button>
    </div>
  )
}

export function RouteErrorBoundary({ children }: { children: React.ReactNode }) {
  return (
    <ErrorBoundary
      FallbackComponent={RouteFallback}
      onError={(error, info) => {
        Sentry.captureException(error, { extra: { componentStack: info.componentStack } })
      }}
    >
      {children}
    </ErrorBoundary>
  )
}

The route boundary does three jobs in one place. It reports the error with the React component stack attached, which makes the Sentry issue actually useful rather than a bare stack trace from the bundler. Users get a recoverable UI rather than a white screen, and a route out of the broken page so they are not stuck. Wrap every top-level route in this and you have eliminated the blank-screen failure mode in one PR.

The feature-level boundary is the same component with a smaller, in-context fallback.

src/app/FeatureErrorBoundary.tsx

import { ErrorBoundary, FallbackProps } from 'react-error-boundary'
import * as Sentry from '@sentry/react'

function FeatureFallback({ error, resetErrorBoundary }: FallbackProps) {
  return (
    <div role="alert" className="feature-error">
      <p>This section is temporarily unavailable.</p>
      <button type="button" onClick={resetErrorBoundary}>
        Retry
      </button>
    </div>
  )
}

export function FeatureErrorBoundary({
  name,
  children,
}: {
  name: string
  children: React.ReactNode
}) {
  return (
    <ErrorBoundary
      FallbackComponent={FeatureFallback}
      onError={error => {
        Sentry.captureException(error, { tags: { feature: name } })
      }}
    >
      {children}
    </ErrorBoundary>
  )
}

The name tag is the payoff. When the recent orders widget throws, the Sentry issue carries feature: recent-orders and the on-call engineer can search for that one widget without combing through every error on the dashboard. Wrap each independent widget on a page in its own boundary and the blast radius of any single bug is reduced to a single card.

Toast plus log plus report, beyond console.error

console.error is invisible to the user, invisible to the team, and invisible to the alerting stack. The three-channel rule is non-negotiable in production: every error notifies the user (toast or inline alert), writes a structured log (something a human can grep), and reports to the issue tracker (Sentry, PostHog, or equivalent). One handler should fan out to all three so the rule cannot be forgotten by accident.

src/lib/errorHandler.ts

import * as Sentry from '@sentry/react'
import { toast } from 'sonner'

interface ReportOptions {
  userMessage?: string
  context?: Record<string, unknown>
  silent?: boolean
}

export function reportError(error: unknown, options: ReportOptions = {}): void {
  const { userMessage, context, silent } = options
  const err = error instanceof Error ? error : new Error(String(error))

  if (!silent && userMessage) {
    toast.error(userMessage)
  }

  console.error('[app-error]', err.message, context ?? {})

  Sentry.captureException(err, {
    extra: context,
  })
}

The handler accepts a userMessage because not every error needs a toast, and a silent flag for background sync failures that should be reported but not surfaced. The structured log prefix makes the entries trivially greppable in any aggregator. Calling code now looks like reportError(err, { userMessage: 'Could not save changes', context: { orderId } }) instead of three lines copy-pasted into every catch block. The discipline is the saving; the consistency is what makes the alerting stack worth setting up.

Dimension 3, type safety

The AI-generated React app usually arrives with TypeScript turned on and strict turned off, which is the worst of both worlds: the build is slow, the IDE pretends to help, and nothing actually catches the bug where the API returns null for a field the component expected to be a string. Type safety in this audit is about two moves: turn the compiler all the way up, then stop hand-typing API responses and start inferring them from a schema.

Removing implicit any from generated code

The first move is a tsconfig.json change.

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "noImplicitAny": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true,
    "noFallthroughCasesInSwitch": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "isolatedModules": true,
    "verbatimModuleSyntax": true,
    "resolveJsonModule": true,
    "baseUrl": ".",
    "paths": { "@/*": ["src/*"] }
  },
  "include": ["src"]
}

strict brings in noImplicitAny, strictNullChecks, and four other flags that the AI's generated code probably ignores. noUncheckedIndexedAccess is the high-value extra: it forces you to handle the fact that array[i] might be undefined, which is the most common production bug in a list-heavy React app. exactOptionalPropertyTypes catches the difference between a prop being absent and a prop being undefined, which matters as soon as you talk to a backend that distinguishes the two.

The compiler will scream the first time. That is the point. The fixes are usually narrow.

src/features/orders/orderTotal.ts

import type { Order } from '@/api/orders'

export function formatCustomerName(order: Order | undefined): string {
  if (!order) return 'Unknown customer'
  return order.customer.trim() || 'Unnamed customer'
}

export function firstOrderTotal(orders: Order[]): string {
  const first = orders[0]
  if (!first) return '0.00'
  return first.total
}

Without noUncheckedIndexedAccess, orders[0].total typechecks and crashes at runtime on an empty list. With the flag on, the compiler insists on the guard and the crash never happens. The cost is a few lines of guard code per indexed access; the benefit is the entire class of "cannot read properties of undefined" errors disappearing from the bug tracker.

Inferring API types from a schema, not from the response

Hand-typed API response interfaces are a fiction. The compiler trusts the interface, the runtime trusts the network, and the two diverge the first time the backend renames a field. A schema-first approach with Zod closes the loop: define the shape once, validate at the boundary, infer the TypeScript type from the same definition.

src/api/orders.ts

import { z } from 'zod'
import { apiClient } from '@/api/client'

export const OrderSchema = z.object({
  id: z.string(),
  customer: z.string(),
  total: z.string(),
  createdAt: z.string().datetime(),
})

export const OrdersResponseSchema = z.array(OrderSchema)

export type Order = z.infer<typeof OrderSchema>

export const ordersApi = {
  recent: async (): Promise<Order[]> => {
    const { data } = await apiClient.get('/orders/recent')
    return OrdersResponseSchema.parse(data)
  },
}

Two things changed. The Order type is now derived from OrderSchema, so the type and the runtime check cannot drift apart. Responses are validated at the network boundary, so a backend that drops a required field fails loudly at the seam rather than silently rendering undefined two layers deeper in the component tree. The cost is the validation pass on every response, which is measured in microseconds for a typical payload. Worth every one of them.

Dimension 4, performance

The AI gets performance backwards. It memoises components that render once and forgets to stabilise the callback that triggers a hundred re-renders elsewhere. The fix is to measure with the React DevTools profiler, find the actual hot paths, and apply memoisation only where it earns its complexity. The official React useMemo docs carry the framework team's own warning that the hook is an optimisation and not a default.

Memoisation and stable references the AI missed

A typical AI-generated component looks like this, with memoisation in the wrong places.

src/features/orders/OrdersList.tsx (before)

import { useMemo } from 'react'
import type { Order } from '@/api/orders'

interface Props {
  orders: Order[]
  onSelect: (id: string) => void
}

export function OrdersList({ orders, onSelect }: Props) {
  const heading = useMemo(() => 'Recent orders', [])
  const sortedOrders = useMemo(
    () => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),
    [orders],
  )

  return (
    <section>
      <h2>{heading}</h2>
      <ul>
        {sortedOrders.map(o => (
          <li key={o.id} onClick={() => onSelect(o.id)}>
            {o.customer}
          </li>
        ))}
      </ul>
    </section>
  )
}

The heading memoisation is pure waste; it caches a string literal that the JavaScript engine deduplicates anyway. Sorting inside sortedOrders is the memo that earns its keep, because the comparison is O(n log n) on every render. Look at the inline arrow on onClick, though, and you find the actual performance bug: every list item gets a fresh function on every parent render, which breaks any React.memo wrapping the list items might have. The fix is to drop the useless memo, keep the useful one, and stabilise the click handler.

src/features/orders/OrdersList.tsx (after)

import { useCallback, useMemo } from 'react'
import type { Order } from '@/api/orders'

interface Props {
  orders: Order[]
  onSelect: (id: string) => void
}

interface RowProps {
  order: Order
  onSelect: (id: string) => void
}

const OrderRow = ({ order, onSelect }: RowProps) => {
  const handleClick = useCallback(() => onSelect(order.id), [order.id, onSelect])
  return (
    <li>
      <button type="button" onClick={handleClick}>
        {order.customer}
      </button>
    </li>
  )
}

export function OrdersList({ orders, onSelect }: Props) {
  const sortedOrders = useMemo(
    () => [...orders].sort((a, b) => a.customer.localeCompare(b.customer)),
    [orders],
  )

  return (
    <section>
      <h2>Recent orders</h2>
      <ul>
        {sortedOrders.map(order => (
          <OrderRow key={order.id} order={order} onSelect={onSelect} />
        ))}
      </ul>
    </section>
  )
}

The trivial memo is gone, the expensive sort is still cached, and the row is its own component so the click handler stays stable per row. useCallback here is not a magic wand for performance; it is a stable-reference tool that lets React.memo actually do its job. The <button> element also gets us keyboard accessibility for free, which the original <li onClick> did not have. React 19's new compiler can automate some of these decisions when you opt in, but it does not exempt you from understanding the trade-off; it only changes who writes the boilerplate.

Bundle size and route-level code splitting

The AI rarely splits the bundle. Every route ships in the initial JavaScript payload, the time-to-interactive metric is whatever the network gives you, and the first paint includes code paths the user has not asked to see. Route-level React.lazy is the single highest-impact performance fix in a React SPA.

src/app/routes.tsx

import { lazy, Suspense } from 'react'
import { Route, Routes } from 'react-router-dom'
import { RouteErrorBoundary } from '@/app/RouteErrorBoundary'

const DashboardPage = lazy(() => import('@/pages/DashboardPage'))
const OrdersPage = lazy(() => import('@/pages/OrdersPage'))
const SettingsPage = lazy(() => import('@/pages/SettingsPage'))

function PageSuspense({ children }: { children: React.ReactNode }) {
  return <Suspense fallback={<div role="status">Loading page</div>}>{children}</Suspense>
}

export function AppRoutes() {
  return (
    <Routes>
      <Route
        path="/"
        element={
          <RouteErrorBoundary>
            <PageSuspense>
              <DashboardPage />
            </PageSuspense>
          </RouteErrorBoundary>
        }
      />
      <Route
        path="/orders"
        element={
          <RouteErrorBoundary>
            <PageSuspense>
              <OrdersPage />
            </PageSuspense>
          </RouteErrorBoundary>
        }
      />
      <Route
        path="/settings"
        element={
          <RouteErrorBoundary>
            <PageSuspense>
              <SettingsPage />
            </PageSuspense>
          </RouteErrorBoundary>
        }
      />
    </Routes>
  )
}

Each page becomes its own chunk, the initial bundle shrinks to the shell plus the first route, and the rest loads on navigation. The RouteErrorBoundary wrap doubles up: a failed chunk download now shows the recovery UI instead of a white screen with a chunk-load error in the console. Run vite build with rollup-plugin-visualizer once after this change and you will see the chunks split out properly; if a chunk is still suspiciously large, the visualiser tells you which dependency is the culprit.

Dimension 5, accessibility

The AI is good at visual fidelity and blind to keyboard fidelity. Generated components use <div onClick> instead of <button>, drop landmarks because the model has no concept of page structure, and add ARIA attributes that contradict the implicit semantics of the underlying element. The fix is a pass that restores semantic HTML, focus management, and the few ARIA attributes that actually do work. The pattern in React Inversion of Control and JSX injection via Context API is one example of how to keep accessible markup intact while still giving consumers control over what gets rendered.

Focus management, semantic landmarks, and aria the AI omitted

A typical AI-generated modal looks plausible and is unusable with a keyboard. It renders into the document where the trigger lives, never moves focus into the dialog, never returns focus when closed, and the escape key does nothing. The production-grade version closes all four gaps.

src/components/Modal.tsx

import { useEffect, useRef } from 'react'
import { createPortal } from 'react-dom'

interface ModalProps {
  isOpen: boolean
  onClose: () => void
  title: string
  children: React.ReactNode
}

export function Modal({ isOpen, onClose, title, children }: ModalProps) {
  const dialogRef = useRef<HTMLDivElement>(null)
  const previousFocusRef = useRef<HTMLElement | null>(null)
  const titleId = useRef(`modal-title-${Math.random().toString(36).slice(2)}`)

  useEffect(() => {
    if (!isOpen) return
    previousFocusRef.current = document.activeElement as HTMLElement | null
    dialogRef.current?.focus()

    const onKeyDown = (event: KeyboardEvent) => {
      if (event.key === 'Escape') onClose()
    }
    document.addEventListener('keydown', onKeyDown)

    return () => {
      document.removeEventListener('keydown', onKeyDown)
      previousFocusRef.current?.focus()
    }
  }, [isOpen, onClose])

  if (!isOpen) return null

  return createPortal(
    <div className="modal-backdrop" onClick={onClose}>
      <div
        ref={dialogRef}
        role="dialog"
        aria-modal="true"
        aria-labelledby={titleId.current}
        tabIndex={-1}
        className="modal-dialog"
        onClick={event => event.stopPropagation()}
      >
        <h2 id={titleId.current}>{title}</h2>
        {children}
        <button type="button" onClick={onClose}>
          Close
        </button>
      </div>
    </div>,
    document.body,
  )
}

Three behaviours snap into place. Focus moves into the dialog on open and returns to the trigger on close, the escape key closes the modal, and the dialog is announced by name through aria-labelledby. The tabIndex={-1} on the dialog container is what lets the focus call land somewhere meaningful before the user tabs to the first interactive element. A full focus trap (cycling Tab and Shift+Tab) is the next step for production; reach for focus-trap-react rather than rewriting it by hand.

While you are in the file, audit the page shell for semantic landmarks. The AI almost certainly nested everything under a single root <div>.

src/app/AppShell.tsx

import { AppHeader } from '@/app/AppHeader'
import { AppNav } from '@/app/AppNav'
import { AppFooter } from '@/app/AppFooter'

interface AppShellProps {
  children: React.ReactNode
}

export function AppShell({ children }: AppShellProps) {
  return (
    <>
      <a href="#main-content" className="skip-link">
        Skip to main content
      </a>
      <AppHeader />
      <div className="layout">
        <AppNav />
        <main id="main-content" tabIndex={-1}>
          {children}
        </main>
      </div>
      <AppFooter />
    </>
  )
}

The skip link, the <main> landmark, and the tabIndex={-1} on <main> together turn the page into something a screen reader user can navigate without listening to the entire header on every page load. One common ARIA mistake to retire while you are here: aria-label on a native <button> that already has text content is wasted; the visible text wins. ARIA is for what HTML cannot express, not a sprinkler system.

Dimension 6, observability

You cannot fix what you cannot see. The AI-generated app ships with console.error for errors and nothing for analytics, which means every production bug starts with "can you describe what you clicked?". The minimum viable observability set-up is two libraries: Sentry for errors and performance, and PostHog for product analytics and session replay. Together they cover the question "what happened" and the question "what was the user trying to do" in one pass.

Adding Sentry or PostHog event hooks at the seams

Sentry first. The React 19 init is a single file, and the React Router integration adds route context to every error automatically.

src/lib/sentry.ts

import * as Sentry from '@sentry/react'

export function initSentry(): void {
  if (!import.meta.env.VITE_SENTRY_DSN) return

  Sentry.init({
    dsn: import.meta.env.VITE_SENTRY_DSN,
    environment: import.meta.env.MODE,
    release: import.meta.env.VITE_APP_VERSION,
    integrations: [
      Sentry.browserTracingIntegration(),
      Sentry.replayIntegration({ maskAllText: true, blockAllMedia: true }),
    ],
    tracesSampleRate: 0.1,
    replaysSessionSampleRate: 0.05,
    replaysOnErrorSampleRate: 1.0,
  })
}

Three knobs matter at first. tracesSampleRate: 0.1 keeps the performance volume manageable, replaysOnErrorSampleRate: 1.0 ensures every error issue ships with a replay, and maskAllText: true is the default that keeps you out of trouble with personal data. The release and environment tags are what make the issue list filterable when you have more than one deploy stream.

PostHog is the second half: identify the user, capture intent events at the seams, and let the product team see funnels rather than guess.

src/lib/posthog.ts

import posthog from 'posthog-js'

export function initPostHog(): void {
  if (!import.meta.env.VITE_POSTHOG_KEY) return

  posthog.init(import.meta.env.VITE_POSTHOG_KEY, {
    api_host: import.meta.env.VITE_POSTHOG_HOST ?? 'https://eu.i.posthog.com',
    capture_pageview: true,
    capture_pageleave: true,
    session_recording: { maskAllInputs: true },
  })
}

export function identifyUser(userId: string, traits: Record<string, unknown>): void {
  posthog.identify(userId, traits)
}

export function trackEvent(name: string, properties?: Record<string, unknown>): void {
  posthog.capture(name, properties)
}

The three seams worth instrumenting first are authentication (sign-up, sign-in, sign-out), the application's equivalent of checkout (the primary conversion event), and the top failure route identified by Sentry. Wiring those three captures gives you a funnel that maps directly on to the bug stream and lets you ask "did the users who hit this error also drop out of the funnel?" without writing custom analytics code. Everything else can wait until you have a real product question to answer.

The wiring at the app root is two lines.

src/app/AppProviders.tsx

import { useEffect } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { PostHogProvider } from '@posthog/react'
import posthog from 'posthog-js'
import { initSentry } from '@/lib/sentry'
import { initPostHog } from '@/lib/posthog'

const queryClient = new QueryClient({
  defaultOptions: { queries: { staleTime: 30_000, retry: 2 } },
})

interface AppProvidersProps {
  children: React.ReactNode
}

export function AppProviders({ children }: AppProvidersProps) {
  useEffect(() => {
    initSentry()
    initPostHog()
  }, [])

  return (
    <PostHogProvider client={posthog}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </PostHogProvider>
  )
}

The useEffect keeps the init calls out of the render path so React StrictMode does not double-init the SDKs in development. Once this lands, the loop closes: an error in production raises a Sentry issue with a replay, the PostHog funnel shows whether the error correlated with a drop-off, and the on-call engineer has both signals on the same incident timeline.

Decision recap, the audit as a repeatable workflow

The six-dimension pass is a checklist a tech lead can paste into a PR template. Run it once per file before any AI-generated React code ships to a real user.

#DimensionFirst fix to applyFailure mode it eliminates
1Data layerTanStack Query or RTK Query, centralised API clientStale data, lost retries, hard-coded URLs
2Error handlingRoute boundary plus a three-channel error handlerWhite-screen crashes, silent failures
3Type safetystrict plus noUncheckedIndexedAccess, Zod at the boundaryRuntime undefined errors, schema drift
4PerformanceStable callbacks, route-level lazy loadingWasted re-renders, oversized initial bundle
5AccessibilitySemantic landmarks, focus management on modalsKeyboard and screen-reader users locked out
6ObservabilitySentry plus PostHog at auth, payment, and top failure routes"Cannot reproduce" tickets, blind incidents

The pattern under the table is the same in every dimension. In each case, the AI produced a plausible component shape and a wrong data, error, or instrumentation layer. Your job is to keep the shape, lift the layer out, and rewrite only the seam that fails the audit. That is much faster than a wholesale rewrite, and it preserves whatever taste the model got right about the UI.

For teams who want this pass automated into their workflow rather than copy-pasted into PRs, the same dimensions form the spine of Vibe Code to Production, my book on taking vibe-coded prototypes to production with React, TypeScript, and AI assistants. The article is the public surface; the book is the workflow. Until then, onboarding to a new codebase with AI tools is the sibling read for applying the same dimensional thinking to a codebase you did not write yourself.

Found this useful?

Share
Thomas Findlay photo

About the author

Written by Thomas Findlay.

Thomas Findlay is a CTO, senior full-stack engineer, and the author of React - The Road To Enterprise and Vue - The Road To Enterprise. With 13+ years of experience, he has built and led engineering teams, architected multi-tenant SaaS platforms, and driven AI-augmented development workflows that cut feature delivery time by 50%+.

Thomas has spoken at international conferences including React Summit, React Advanced London, and Vue Amsterdam, and has written 50+ in-depth technical articles for the Telerik/Progress blog. He holds an MSc in Advanced Computer Science (Distinction) from the University of Exeter and a First-Class BSc in Web Design & Development from Northumbria University.

With a 5-star rating across 2,500+ mentoring sessions and 1,250+ reviews on Codementor, Thomas has helped developers and teams worldwide with architecture consulting, code reviews, and hands-on development support. Find him on LinkedIn, GitHub, or Twitter/X, or get in touch directly. Read the full bio →

Stop fighting your codebase. Start shipping.

Long-form articles like this one, plus a 400+ page book that takes you end-to-end through production React in TypeScript.