How to make a Vite or CRA app production-ready
A default Vite or Create React App scaffold is a great starting point for a demo. It is not a starting point for a production app. Here is the concrete playbook to close the gap.
The default Vite or CRA scaffold gives you: a build tool, a dev server, a sample component, a sample test, and nothing else. That is fine for showing a friend. To ship to paying users it needs another seven things. None of them are exotic.
This playbook assumes Vite + React + TypeScript. If you are on CRA: most of the steps are identical, but consider migrating to Vite first. CRA is officially archived and accumulating CVEs in react-scripts. The migration is a half-day project.
Starting point
npm create vite@latest my-app -- --template react-ts
cd my-app
npm install
You now have src/App.tsx, vite.config.ts, and a working npm run dev. Run it once to confirm. Then we make it production-ready.
Step 1 — Strict TypeScript
Open tsconfig.json and ensure these are set:
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": true
}
}
strict: true is the floor. The other four catch entire classes of subtle bugs. noUncheckedIndexedAccess in particular saves you from runtime "cannot read property of undefined" errors that the AI happily generated.
Add a typecheck script:
// package.json
"scripts": {
"typecheck": "tsc --noEmit"
}
Step 2 — ESLint and Prettier
Vite's React-TS template ships ESLint. Verify it works: npm run lint. If it does not exist or is missing rules, install:
npm install -D eslint @typescript-eslint/parser @typescript-eslint/eslint-plugin \
eslint-plugin-react eslint-plugin-react-hooks eslint-plugin-import \
prettier eslint-config-prettier
Minimal .eslintrc.cjs:
module.exports = {
root: true,
env: { browser: true, es2022: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
'prettier'
],
parser: '@typescript-eslint/parser',
plugins: ['@typescript-eslint', 'react-refresh'],
rules: {
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
}
Add to package.json:
"scripts": {
"lint": "eslint src --ext ts,tsx",
"format": "prettier --write src"
}
Step 3 — Real tests, not the default placeholder
Install Vitest:
npm install -D vitest @testing-library/react @testing-library/jest-dom @vitest/ui jsdom
Add to vite.config.ts:
/// <reference types="vitest" />
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test-setup.ts'],
},
});
Create src/test-setup.ts:
import '@testing-library/jest-dom';
Add scripts:
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "vitest --ui"
}
Now write at least three real tests: one for your auth flow, one for your payment / checkout flow, one for the core happy path of your product. These three save you from the worst regressions. Skip aiming for 100% coverage; aim for "the things that matter cannot break silently."
Step 4 — Continuous integration
Drop in .github/workflows/ci.yml:
name: CI
on:
pull_request:
push:
branches: [main]
jobs:
ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run lint
- run: npm run typecheck
- run: npm test
- run: npm run build
- run: npm audit --audit-level=high
Now every PR and every push to main runs lint, typecheck, tests, build, and an audit for known CVEs. The day you accidentally introduce a high-severity vulnerability or a type error, GitHub stops you from merging it.
Step 5 — Error tracking with Sentry
Sign up for Sentry's free tier, create a React project, get the DSN.
npm install @sentry/react
In src/main.tsx:
import * as Sentry from '@sentry/react';
if (import.meta.env.PROD) {
Sentry.init({
dsn: import.meta.env.VITE_SENTRY_DSN,
environment: import.meta.env.MODE,
tracesSampleRate: 0.1,
replaysSessionSampleRate: 0, // session replay is paid; skip for now
replaysOnErrorSampleRate: 1.0,
});
}
Wrap your root with Sentry.ErrorBoundary for component errors, and you are done. Every uncaught exception now lands in Sentry with a stack trace and breadcrumbs.
Step 6 — Environment variables, properly
Vite exposes only env vars prefixed VITE_ to the client bundle. Use this distinction.
- Anything that should be public (DSN, public API URLs, feature flags):
VITE_FOO. Lands in the bundle. Visible to users. - Anything secret (API keys, database URLs, service-role tokens): not prefixed. These belong on a backend, not in the React app.
If you put your OpenAI key in VITE_OPENAI_KEY, congratulations — every visitor of your site can read it from the JS bundle. Move it to a backend route.
Commit .env.example, never .env:
# .gitignore
.env
.env.local
.env.production
.env.*
!.env.example
Step 7 — Deployment with security headers
If you deploy to Vercel, add vercel.json:
{
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Frame-Options", "value": "SAMEORIGIN" },
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" },
{ "key": "Permissions-Policy", "value": "camera=(), microphone=(), geolocation=()" },
{ "key": "Strict-Transport-Security", "value": "max-age=63072000; includeSubDomains; preload" }
]
}
]
}
If you deploy to Netlify, the same in _headers or netlify.toml. If you self-host with nginx, add the headers there.
Test what you got at securityheaders.com. Aim for an A.
Step 8 — Bundle size sanity
Run a build:
npm run build
Look at the output. If your main chunk is over 300 KB gzipped, something is wrong — usually you imported a giant library you barely use. Common culprits: moment (use date-fns or native Intl), lodash (use individual imports or built-ins), full icon libraries instead of tree-shaken imports, charting libraries you only use one chart from.
Install the visualizer to actually see what is in your bundle:
npm install -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
plugins: [react(), visualizer({ open: true, gzipSize: true })],
Run npm run build and the visualization opens in your browser. The expensive culprits become obvious.
Step 9 — Health endpoint and uptime monitoring
If your Vite app is purely static, the host's HTTP probe is fine — point UptimeRobot at https://yourapp.com. If you have an API, expose /health returning JSON:
// example: a small Express backend
app.get('/health', (_, res) => res.json({ ok: true, ts: Date.now() }));
Sign up for a free uptime monitor (UptimeRobot, Better Stack) and set a 1-minute check. You are now notified within 60 seconds of any outage.
The total time investment
Steps 1–4 (typecheck, lint, tests, CI): half a day. Steps 5–7 (Sentry, env hygiene, security headers): two hours. Steps 8–9 (bundle audit, uptime): another hour.
You will spend roughly one weekend doing this. The day you have a paying customer, you will be glad you did. The day you have a hundred, even more.
What this does not cover
This playbook covers the frontend hardening. If your app has a backend, you also need: rate limiting, input validation (use zod), CORS allowlist, structured logging with secret redaction, database backups, and a migration strategy. The common security gaps post covers most of those.
How to know what is missing in your repo
Run a free CodeClanker scan. We will tell you which of these nine items are missing in your specific codebase, plus check for committed secrets, vulnerable dependencies via OSV.dev, and license issues. The output is the prioritized fix list, not a generic checklist.
Skip the checklist — get a personalized one
Free 60-second scan. We name every gap with the file or dependency that proves it.
Run a free scan →