Four Things I Forget About Create-React-App Every Year
Cheat sheet for the CRA quirks that keep coming back. Absolute imports via jsconfig, the HTTPS dev flag, the registerServiceWorker mystery, and the REACT_APP_ env var rules.
By @yunatorres
March 29, 2026
·
Updated August 12, 2026
311 views
6
4.3 (13)
// CRA respects a jsconfig.json (or tsconfig.json) at the project root with
// compilerOptions.baseUrl = "src". With it, every import path is rooted at
// src/, so deep components can write `import Button from 'components/Button'`
// instead of `import Button from '../../../components/Button'`.
//
// The config lives next to package.json. CRA reads it on dev-server start;
// changing it requires a server restart.
const jsconfig = {
compilerOptions: {
baseUrl: 'src',
},
include: ['src'],
};
console.log('jsconfig.json contents:');
console.log(JSON.stringify(jsconfig, null, 2));
// Before. Imagine src/pages/dashboard/widgets/SalesWidget.js:
const before = [
"import Button from '../../../components/Button';",
"import { fetchSales } from '../../../api/sales';",
"import { formatMoney } from '../../../utils/format';",
];
console.log('\
BEFORE (relative paths from a deep file):');
before.forEach((line) => console.log(' ' + line));
// After. Same file, with baseUrl: 'src' set.
const after = [
"import Button from 'components/Button';",
"import { fetchSales } from 'api/sales';",
"import { formatMoney } from 'utils/format';",
];
console.log('\
AFTER (absolute from src/):');
after.forEach((line) => console.log(' ' + line));
// Gotchas I have hit twice each.
console.log('\
Gotchas:');
console.log(' - paths option (compilerOptions.paths) does NOT work in CRA.');
console.log(' Only baseUrl. For path aliases you need react-app-rewired or eject.');
console.log(' - jsconfig.json must be at project root, not under src/.');
console.log(' - the dev server caches the resolution; restart yarn start after editing.');
console.log(' - VS Code uses jsconfig too. Once it picks up baseUrl, autoimport stops');
console.log(' inserting the relative paths and starts inserting the absolute ones.');
// TypeScript variant. The same compilerOptions live in tsconfig.json.
const tsconfig = { compilerOptions: { baseUrl: 'src', target: 'es6', jsx: 'react-jsx' } };
console.log('\
TS variant tsconfig.json:');
console.log(JSON.stringify(tsconfig, null, 2));Two minutes of config that ages well. The whole behaviour is one line: compilerOptions.baseUrl: 'src' in jsconfig.json (or tsconfig.json for TS projects), and CRA picks it up on the next dev-server start. Every import becomes rootable from src/, so a file three levels deep does not have to count ../../../. The two gotchas worth flagging are that the paths field for arbitrary aliases is not supported by CRA without ejecting or react-app-rewired, and that VS Code uses the same jsconfig.json for autoimport, so once you add it your tooling stops suggesting relative paths and quietly switches to the absolute ones. Modern bundlers (Vite, esbuild) handle this with resolve.alias; CRA's path is the JSON file.
// Two CRA env knobs cover almost every variation: HTTPS=true forces the dev
// server to use TLS, and SSL_CRT_FILE / SSL_KEY_FILE override CRA's self-
// signed cert with one your browser already trusts. Use case: cookies that
// require Secure, third-party SDKs (Google sign-in, Stripe Elements) that
// will not load over http://localhost.
const shellInvocations = [
{
title: 'one-shot from the shell',
cmd: 'HTTPS=true yarn start',
notes: 'works on macOS and Linux. Windows: $env:HTTPS="true"; yarn start',
},
{
title: 'pinned in package.json scripts',
cmd: '"start:https": "HTTPS=true react-scripts start"',
notes: 'only commit the cross-env variant if you have Windows teammates',
},
{
title: 'pinned in .env.development.local',
cmd: 'HTTPS=true',
notes: '.local files are gitignored by CRA conventions; safe for personal toggles',
},
];
console.log('Three ways to flip HTTPS on. Pick by audience:');
shellInvocations.forEach((s) => console.log(' -', s.title, '->', s.cmd, '|', s.notes));
// Trusting the dev cert. CRA generates one per project, and Chrome / Safari
// both refuse it on first run. The local CA approach is the one that scales.
console.log('\
Trusting the dev cert (mac):');
console.log(' brew install mkcert');
console.log(' mkcert -install # registers a local CA');
console.log(' mkcert localhost 127.0.0.1 # produces .pem files');
console.log(' HTTPS=true SSL_CRT_FILE=./localhost.pem SSL_KEY_FILE=./localhost-key.pem yarn start');
console.log('\
Why the certificate matters in practice:');
console.log(' - Cookies marked Secure are dropped by the browser over plain http://localhost,');
console.log(' so auth flows that depend on Secure cookies fail in dev unless HTTPS is on.');
console.log(' - Google sign-in, Stripe Elements, and most SDKs refuse to load on http://.');
console.log(' - Service workers only register on https:// (localhost is whitelisted, but the');
console.log(' moment you bind to a LAN IP for testing on a phone, you need a real cert).');
// Sanity. If the env var is set, CRA injects it as process.env.HTTPS.
const pretendEnv = { HTTPS: 'true', SSL_CRT_FILE: './localhost.pem', SSL_KEY_FILE: './localhost-key.pem' };
console.log('\
Dev process.env CRA would see:');
Object.keys(pretendEnv).forEach((k) => console.log(' ', k, '=', pretendEnv[k]));I always forget which variable controls which behaviour, so the cheat sheet lives in this snippet. HTTPS=true is the only one most people need; the cert files matter the moment you want a green padlock or a real subject name on the cert (mobile testing on a LAN IP is the usual reason). mkcert is the easy path for local CA generation: install once, generate per-project certs, point CRA at them with SSL_CRT_FILE and SSL_KEY_FILE. The unintuitive bit is why you would bother in dev: cookies with Secure get dropped by the browser over http://localhost, so an auth flow that works in production silently fails in dev unless HTTPS is on. The same bites you with service workers, third-party SDKs, and anything that gates on window.isSecureContext.
// Every CRA project from 2.0+ ships with src/serviceWorker.js or
// src/serviceWorkerRegistration.ts. Index.js calls one of:
// serviceWorker.unregister(); // <-- the default since CRA 3.0
// serviceWorker.register(); // <-- only if you want offline-first
//
// The history is that CRA 1.x called register() by default, which broke a lot
// of teams whose deploys rotated the service worker but left users stuck on
// stale bundles. CRA 3 flipped the default to unregister(). I have never
// turned it back on.
// Pseudo of what register and unregister do.
function makeRegistrar() {
let installed = null;
return {
register: function (config) {
if (typeof navigator === 'undefined' || !navigator.serviceWorker) {
console.log(' -> skipping: no serviceWorker support');
return;
}
installed = config || { scope: '/' };
console.log(' -> registered with scope', installed.scope, '(would precache the build)');
},
unregister: function () {
if (!installed) { console.log(' -> nothing to unregister'); return; }
console.log(' -> unregistered worker for scope', installed.scope);
installed = null;
},
status: function () { return installed ? 'active' : 'unregistered'; },
};
}
const sw = makeRegistrar();
console.log('Default in modern CRA, src/index.js calls:');
console.log(' serviceWorker.unregister();');
sw.register({ scope: '/' });
sw.unregister();
console.log(' status:', sw.status());
console.log('\
If you opt in (offline-first PWA):');
console.log(' serviceWorker.register({ onUpdate: (reg) => reg.waiting && reg.waiting.postMessage({ type: "SKIP_WAITING" }) });');
sw.register({ scope: '/', onUpdate: () => {} });
console.log(' status:', sw.status());
console.log('\
Why I keep it unregistered for most apps:');
console.log(' - PWAs need a deliberate cache-bust strategy. Without one, deploys ship');
console.log(' new HTML but users keep seeing the old bundle until the worker decides');
console.log(' to update, which can be hours or days.');
console.log(' - Stale workers + stale precaches caused at least three painful incidents');
console.log(' at past jobs before we just stopped registering.');
console.log(' - If you DO want PWA, use Workbox or vite-plugin-pwa where the cache');
console.log(' invalidation strategy is first-class, not an afterthought.');
// Worth knowing for legacy code: how to FORCE all users off an old worker.
console.log('\
Kill-switch for an old worker that is still hanging around:');
console.log(' navigator.serviceWorker.getRegistrations().then(rs => rs.forEach(r => r.unregister()));');
console.log(' caches.keys().then(keys => keys.forEach(k => caches.delete(k)));');
console.log('Run those once on the next deploy and the next refresh is clean.');The shape of this part of CRA is a lot more interesting than it sounds. The repo ships with the wiring for an offline-first PWA, but ships with it disabled because the offline-first cache strategy has burned more teams than it has helped. The kill-switch at the bottom is the most useful piece: if you inherit a project where users still see stale UI weeks after a deploy, it is almost always because an old service worker is precaching the old bundle. The two-line getRegistrations + caches.delete snippet drops into a fresh deploy and clears it on the next visit. If you actually want PWA behaviour, the modern recommendation is Workbox or vite-plugin-pwa; CRA's bundled worker is a 2018 design that did not age well.
// CRA only inlines env vars that start with REACT_APP_. The intent is to
// keep accidental secrets (from your shell, from your shell history, from
// your CI runner) out of the client bundle. The cost is one extra rule to
// remember, but it has saved me real money at least twice.
// File priority (highest first). Anything earlier wins.
const envFileCascade = [
'.env.development.local', // dev only, gitignored
'.env.test.local', // test only, gitignored
'.env.production.local', // prod only, gitignored
'.env.local', // any env, gitignored
'.env.development', // dev only, committed
'.env.test', // test only, committed
'.env.production', // prod only, committed
'.env', // any env, committed
];
console.log('CRA env file cascade (earlier wins):');
envFileCascade.forEach((f, i) => console.log(' ' + (i + 1) + '. ' + f));
// What gets inlined.
const shellEnv = {
HOME: '/Users/me', // ignored
AWS_SECRET_ACCESS_KEY: 'sshhh', // ignored, ON PURPOSE
REACT_APP_API_URL: 'https://api.example.com', // inlined
REACT_APP_FEATURE_BETA: 'true', // inlined
NODE_ENV: 'development', // inlined (special)
PUBLIC_URL: '/app', // inlined (special)
};
function filterCRA(env) {
const out = {};
for (const k of Object.keys(env)) {
if (k.startsWith('REACT_APP_') || k === 'NODE_ENV' || k === 'PUBLIC_URL') {
out[k] = env[k];
}
}
return out;
}
console.log('\
Shell env present at build:', Object.keys(shellEnv));
console.log('What CRA actually inlines into process.env:');
const inlined = filterCRA(shellEnv);
Object.keys(inlined).forEach((k) => console.log(' ', k, '=', inlined[k]));
console.log('\
Noticed that AWS_SECRET_ACCESS_KEY did NOT make it into the client?');
console.log(' That is the whole feature. Anything outside the whitelist is invisible to');
console.log(' the bundle, even if a teammate accidentally exports it in their shell rc.');
// Reading the var in app code.
console.log('\
In-app usage:');
console.log(' const apiUrl = process.env.REACT_APP_API_URL;');
console.log(' const isBeta = process.env.REACT_APP_FEATURE_BETA === "true";');
console.log('Note both are STRINGS. "false" is truthy; you must compare to the literal.');
// The rebuild gotcha.
console.log('\
Rebuild gotcha:');
console.log(' CRA inlines env vars at BUILD time, not runtime. Changing .env requires');
console.log(' a fresh `yarn start` (dev) or `yarn build` (prod). A running dev server');
console.log(' will not pick up changes until you restart. This bites everyone once.');
// Runtime config alternative.
console.log('\
If you need runtime-changeable config (different API per environment with the');
console.log('same build artifact), serve a /config.json from your CDN and fetch it on app');
console.log('boot. Build-time inlining is fast and cacheable; runtime config is flexible.');Three rules cover almost every CRA env-var question. First, the prefix: REACT_APP_ (or the two specials NODE_ENV and PUBLIC_URL) is what gets inlined into the bundle, everything else is invisible. Second, the cascade: .env.development.local beats .env.development beats .env; the .local files are gitignored by CRA convention so personal overrides do not leak into the repo. Third, the rebuild gotcha: env vars are baked into the bundle at build time, not read at runtime, so a config change after yarn start requires a server restart in dev or a fresh yarn build in CI. The runtime-config alternative at the bottom is the escape hatch: ship a single build artifact, serve a tiny /config.json per environment, fetch it on boot, and you get same-bundle-multi-env without rebuilding.
