Why I Built create-react-adam (And Why I Reach For It)

Adam Sturge | Apr 27, 2026 min read

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 / Redirect mental model as React Router. Unless you need React Router’s data APIs, this is enough.
  • Tailwind CSS via @tailwindcss/vite. No tailwind.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/js recommended
  • typescript-eslint strict preset (not just recommended)
  • eslint-plugin-jsx-a11y in strict mode
  • eslint-plugin-react-hooks
  • eslint-plugin-react-refresh
  • eslint-plugin-import (with TypeScript resolver)
  • eslint-plugin-promise
  • eslint-plugin-unicorn
  • eslint-plugin-de-morgan — flags logical expressions that simplify under De Morgan’s laws
  • eslint-config-prettier to 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: true
  • forbidOnly: !!process.env.CI — fails CI if you accidentally commit .only
  • retries: 2 in CI, 0 locally
  • trace: "on-first-retry" — full interaction trace whenever a flake happens
  • webServer auto-starts npm run dev so tests just work
  • reporter: [["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:

  1. 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-primaryActive and you’re done.
  2. 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-e2e and --no-utils exist 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.