Overview
Every new React project I would waste time on configuration: Tailwind, ESLint, Prettier plugins, Renovate rules, Playwright wiring, GitHub Actions, a color palette, a router. So I packaged my answers into create-react-adam to save time on future projects.
The philosophy: opinionated where opinions don’t matter (linting, formatting, CI), lightweight where they do (Wouter instead of React Router, no PostCSS config, exact dependency pins), strict TypeScript everywhere, and accessibility linting on by default.
Quick start
npx create-react-adam@latest my-app --with-e2e --with-utils
The flags:
--with-e2e/--no-e2e— include or skip the Playwright + Allure setup--with-utils/--no-utils— include or skip the bundled hooks/helpers--dir <path>— write into a custom directory
If you skip the flags, the CLI prompts you for each. For the full step-by-step, see Setting Up a React Tailwind Project with create-react-adam.
The core stack
- React with the new JSX transform
- Vite — dev server and bundler
- TypeScript — strict
- Wouter — routing in ~1.3 kB. Same
Switch/Route/Link/Redirectmental model as React Router. Unless you need React Router’s data APIs, this is enough. - Tailwind CSS via
@tailwindcss/vite. Notailwind.config.js, no PostCSS config — the theme lives in CSS via@theme. - React Icons for the icon set
Lint and format options
Lint is ESLint flat config — no legacy .eslintrc JSON. The stack:
@eslint/jsrecommendedtypescript-eslintstrict preset (not just recommended)eslint-plugin-jsx-a11yin strict modeeslint-plugin-react-hookseslint-plugin-react-refresheslint-plugin-import(with TypeScript resolver)eslint-plugin-promiseeslint-plugin-unicorneslint-plugin-de-morgan— flags logical expressions that simplify under De Morgan’s lawseslint-config-prettierto disable rules Prettier handles
The unused-vars rule is customized so anything prefixed with _ is treated as intentional:
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
caughtErrorsIgnorePattern: "^_",
},
]
Prettier ships with two plugins worth calling out:
- prettier-plugin-organize-imports — sorts imports and removes unused ones every save
- prettier-plugin-tailwindcss — sorts class names so PR diffs stay clean
Scripts: lint:check, lint (auto-fix), format:check, format. There’s also setFormatToPrecommitHook if you want format/lint to run on every commit.
Renovate
Renovate is wired up in two different ways depending on which side of the line you’re on.
The scaffolder repo uses an aggressive automerge configuration:
{
"extends": ["config:recommended"],
"platformAutomerge": true,
"packageRules": [
{
"matchManagers": ["npm", "github-actions", "nvm"],
"groupName": "All Dependencies",
"schedule": ["at any time"],
"automerge": true
}
]
}
One combined PR, automerged once the check + e2e workflows pass. The CI suite is the gate.
Generated projects get a more conservative version: three groups (JavaScript Dependencies, GitHub Actions Dependencies, Node Version Manager Dependencies), no automerge. Quieter PRs, but you stay in control.
Both pair with save-exact=true in .npmrc, which pins every dependency to an exact version. Renovate diffs become unambiguous, and “works on my machine” stops being a thing.
Optional: end-to-end tests (--with-e2e)
E2E lives in its own e2e/ workspace with its own package.json so Playwright and Allure don’t pollute the app’s dependency tree.
The Playwright config has a few details I lean on:
fullyParallel: trueforbidOnly: !!process.env.CI— fails CI if you accidentally commit.onlyretries: 2in CI,0locallytrace: "on-first-retry"— full interaction trace whenever a flake happenswebServerauto-startsnpm run devso tests just workreporter: [["line"], ["allure-playwright"]]— line output during runs, Allure for the rich post-mortem
Three scripts:
npm run test:e2e # headless run
npm run test:e2e:ui # Playwright UI mode for debugging
npm run test:e2e:report # generate and open the Allure report
The sample test demonstrates role-based selectors — more resilient than CSS selectors:
test("basic navigation works", async ({ page }) => {
await page.goto("/");
await page.getByRole("link", { name: /about/i }).click();
await expect(page).toHaveURL("/about");
await page.getByRole("link", { name: /home/i }).click();
await expect(page).toHaveURL("/");
});
Optional: utils (--with-utils)
The src/utils/ directory ships five small, focused pieces. None of them are big enough to be a library, but I keep rewriting them, so they live here.
classNames(...classes)
classNames("px-4", isActive && "bg-brand-primary", disabled && "opacity-50");
clsx without the dependency. Filters falsy values and joins with spaces. Most projects don’t need more than this.
Storage and useReactPersist
Storage is a typed wrapper around localStorage with automatic JSON serialization and an optional secondsTillExpiry for TTL. useReactPersist is the React hook on top — same [value, setter] tuple as useState, but persistent:
const [theme, setTheme] = useReactPersist("theme", "light");
const [session, setSession] = useReactPersist(
"session",
{},
{ secondsTillExpiry: 3600 },
);
useUrlState(key, defaultValue)
Two-way binds a numeric piece of state to a query parameter via Wouter’s useLocation and useSearch:
const [page, setPage] = useUrlState("page", 1);
Set page to 2 and the URL becomes ?page=2. Reload the page, the value comes back. Great for paginators, tab indices, filter counts — anything you want to be shareable. Currently numeric-only.
useInternetConnected(callback, deps)
Fires the callback immediately if navigator.onLine is true, then again every time the browser dispatches an online event. Cleans up on unmount.
useInternetConnected(() => syncPendingChanges(), []);
I use this for re-syncing data and retrying failed requests after a reconnect.
safeTimeout(delay, ...)
Clamps delays past 2^31 − 1 ms (~24.8 days) so a long timeout doesn’t silently overflow back to nearly-zero. Edge case, but it’s there.
If you scaffold with --no-utils, the CLI swaps the demo Home page for a no-utils variant so the example still renders cleanly.
Color profile and theming
The Tailwind @theme block lives in src/app.css. No separate config file:
@import "tailwindcss";
@theme {
--font-sans: "Inter", sans-serif;
--color-brand-primary: #3b82f6;
--color-brand-primaryHover: #2563eb;
--color-brand-primaryActive: #1d4ed8;
--color-brand-secondary: #8b5cf6;
--color-brand-secondaryHover: #7c3aed;
--color-brand-secondaryActive: #6d28d9;
--color-brand-success: #22c55e;
--color-brand-warning: #f59e0b;
--color-brand-danger: #ef4444;
--color-brand-gray: #64748b;
--color-brand-grayLight: #e2e8f0;
--color-brand-grayDark: #334155;
--color-brand-background: #f8fafc;
--color-brand-border: #cbd5e1;
--color-brand-disabled: #94a3b8;
}
Two things that matter here:
- Every interactive color has a paired hover and active. Buttons and links never have to invent a one-off darker shade —
bg-brand-primary hover:bg-brand-primaryHover active:bg-brand-primaryActiveand you’re done. - It’s all CSS variables. Light theme out of the box, but a dark-mode swap is just reassigning the same names under a
[data-theme="dark"]selector. No toggle ships, but the foundation is ready.
CI you didn’t have to write
Two GitHub Actions workflows ship in .github/workflows/:
check.yml runs on every push and PR:
npx tsc --noEmit
npm run format:check
npm run lint:check
npm run build
Type errors, formatting drift, lint failures, and bundle problems all caught before merge.
e2e.yml installs both root and e2e/ dependencies, runs playwright install --with-deps chromium, executes the suite, and uploads both playwright-report and allure-results as artifacts (if: always(), 30-day retention) so failures stay debuggable.
Both pin Node to match .nvmrc, with cache: "npm" for speed.
Strict TypeScript
Worth naming, even if it’s not the headline. tsconfig.json enables strict, noUnusedLocals, noUnusedParameters, noFallthroughCasesInSwitch, and noUncheckedSideEffectImports. Module resolution is bundler for Vite, JSX is the new react-jsx transform. Most bugs surface at compile time instead of runtime.
Deploying it
The build output is plain static assets, so it deploys anywhere. My default is Cloudflare Pages — see Deploying a React Site to Cloudflare Pages for the Git-connected static deploy and the SPA routing fallback you need for Wouter to work on a hard refresh.
Why this is what I reach for
- It’s opinionated where opinions don’t matter (linting, formatting, CI) and ergonomic where they do (utils, colors, routing).
- Every dependency is exact-pinned, every Renovate PR is grouped, and the CI suite gates the merge — so I update aggressively without fear.
--no-e2eand--no-utilsexist so a throwaway prototype scaffolds just as cleanly as a production app.
The full source is at github.com/adamjsturge/createAdamReact. Issues and PRs welcome.