One coherent stack for building type-safe TypeScript apps: Vite 8 (build + dev server, Rolldown-powered), React 19.3 with the React Compiler, TypeScript 7.0 (strict, Go-native), Tailwind CSS v4.3 + shadcn/ui for styling, Biome 2.5 for linting and formatting, Vitest 5 for testing, and Hono 4 for the backend/edge API. The pieces are designed to fit together - this skill covers how they wire up and the sharp edges that span more than one of them. Hono's RPC client ( hc) shares server types directly with the React frontend, so the front and back end stay type-safe end to end without codegen.
The body below is the cross-cutting layer: the rules that bite when these tools meet, plus one working end-to-end setup. Each tool also has a deep-dive reference - read the one you need:
use(), Activity, <ViewTransition>, Fragment refs, useEffectEvent, document metadata, and the React Compiler.import defer.data-slot, the cn package, registries, Base UI / Radix / React Aria.biome check, domains, type-aware linting, GritQL, ESLint/Prettier migration.ToolVersionNoteVite8.3.3Rolldown is the single default bundler@vitejs/plugin-react6.1.2v6 removed the inline babel optionReact / react-dom19.3.0React Compiler is stable (1.0)babel-plugin-react-compiler1.0.0pin with --save-exactTypeScript7.0.2Go-native, ~10x faster; ships no JS API (6.0 compat path in typescript.md)Tailwind CSS4.3.3CSS-first config, no JS config fileshadcn/ui CLI4.21.3Base UI is the default base; cn is its own packageBiome2.5.15single binary for lint + format + importsVitest5.0.3Vite-native test runner; reuses vite.configHono4.13.13Web Standards backend/edge framework; v5 in development
Node.js 22.12+ is the effective floor for the stack: Vite alone accepts 20.19+, but Vitest 5 and @rolldown/plugin-babel both require 22.12+.
These are the rules that fail in confusing ways precisely because they sit at the seam between two tools. The single-tool details live in the references.
react() last#When a framework plugin (TanStack Router/Start, etc.) generates routes or transforms code, it must run before @vitejs/plugin-react so React's Fast Refresh transform sees the final output. Wrong order causes route-generation failures and broken HMR.
ts
plugins: [
tanstackStart(), // or tanstackRouter() for SPA - framework first
tailwindcss(),
react(), // React plugin last among framework plugins
]
React Compiler 1.0 auto-memoizes components, computations, and callbacks at build time. Write plain components; do not reach for useMemo/ useCallback/ memo. The catch lives at the Vite seam: @vitejs/plugin-react v6 removed the inline babel option, so the old react({ babel: { plugins: [...] } }) wiring no longer works. The compiler now runs through a separate Babel plugin:
ts
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
plugins: [react(), babel({ presets: [reactCompilerPreset()] })]
Install: pnpm add -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler @types/babel__core.
plugin-react 6.1 also ships an experimental native (Rust) compiler path, react({ compiler: true }), that skips Babel entirely - see react.md for its install trap. The Babel wiring above remains the stable route.
This also ripples into Biome: useExhaustiveDependencies can't tell the compiler is handling deps for you, so most compiler users turn it off (see biome.md).
tailwind.config.js#Tailwind v4 configures everything in CSS via @theme, @utility, @plugin, @source. Never create or look for tailwind.config.js/ .ts. The Vite integration is the @tailwindcss/vite plugin (no PostCSS config either). If you find a tailwind.config.js in a v4 project, it is leftover - delete it and migrate the values into CSS. Full details in tailwind.md.
tsx
<div className="bg-primary text-primary-foreground"> // respects theme + dark mode
<div className="bg-blue-500 text-white"> // breaks theming - avoid
And never assemble class names from fragments ( bg-${color}-500) - Tailwind's scanner only sees complete literal strings, so dynamic names silently produce no CSS. Use a lookup map of full class strings.
TS 6.0 and 7.0 bake in much of what used to be manual: strict and noUncheckedSideEffectImports are on by default, so drop them from a fresh tsconfig. Two defaults break a Vite app if ignored: types defaults to [], and side-effect imports are now checked - so import "./styles.css" fails (TS2882) and import.meta.env is untyped (TS2339) until you add "types": ["vite/client"] (append "node" etc. as needed). baseUrl is deprecated - use prefixed paths.
A plain pnpm add -D typescript now installs 7.0, which ships the tsc binary but no programmatic API (it returns until 7.1). This stack is fine - Biome, plugin-react, and the shadcn CLI never import typescript - but typescript-eslint, framework checkers (Vue, Astro, Svelte, MDX), and anything else that does import ts from "typescript" need the official 6.0 alias pair. See typescript.md.
files.includes is the only include key#Run biome check (or biome ci) - it formats, lints, and organizes imports in a single pass; never split into separate lint + format calls. And in Biome 2.x the only file-selection key is files.includes (with the s); files.ignore/ files.include/ files.exclude do not exist and throw Found an unknown key. Exclude with negation: "includes": ["**", "!**/routeTree.gen.ts"]. More in biome.md.
When the API is Hono, the React app talks to it through the hc<AppType>() client, which
imports the server's exported typeof app directly. That shared type is the seam: it only
works if both sides run the same Hono version and both tsconfig.json set "strict": true
(a mismatch throws "Type instantiation is excessively deep"). Two more rules that bite at this
seam: handlers must specify status codes ( c.json(data, 200)) for the client to infer
responses, and routes the client calls must not use c.notFound(). As the route count grows,
compile the client type once ( hcWithType) so the IDE stays fast. Run pnpm why hono to catch a
second copy pulled in by an adapter's peer range. If Hono handles CORS behind the Vite dev server,
set server.cors: false in vite.config.ts so the two CORS layers don't conflict. Full details in
hono.md.
A minimal but complete React + TypeScript + Tailwind + Biome project. Swap the framework plugin for your router/SSR choice (see vite.md for TanStack and Cloudflare variants).
ts
import { defineConfig } from 'vite'
import react, { reactCompilerPreset } from '@vitejs/plugin-react'
import babel from '@rolldown/plugin-babel'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [
tailwindcss(),
react(),
babel({ presets: [reactCompilerPreset()] }),
],
resolve: {
alias: { '@': new URL('./src', import.meta.url).pathname },
},
})
import.meta.url is the ESM-correct way to resolve paths - there is no __dirname in an ESM config, and Vite configs are ESM-only.
jsonc
{
"compilerOptions": {
// strict + noUncheckedSideEffectImports are ON by default since 6.0 - omitted on purpose
"target": "es2023",
"module": "preserve",
"moduleResolution": "bundler",
"moduleDetection": "force",
"jsx": "react-jsx",
"verbatimModuleSyntax": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"erasableSyntaxOnly": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["vite/client"],
"paths": { "@/*": ["./src/*"] }
},
"include": ["src"]
}
module: preserve + moduleResolution: bundler is the right pairing for a Vite-bundled app; use nodenext instead only for Node-executed code. "types": ["vite/client"] is load-bearing: it declares CSS/asset modules and import.meta.env, without which the default-on side-effect-import check rejects import "./styles.css". Other ambient types stay out until you list them ( "node", "vitest/globals"). exactOptionalPropertyTypes means an optional prop that may receive undefined must be declared prop?: T | undefined.
json
{
"$schema": "./node_modules/@biomejs/biome/configuration_schema.json",
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
"files": { "includes": ["**", "!**/components/ui", "!**/routeTree.gen.ts"] },
"formatter": { "enabled": true, "indentStyle": "space", "lineWidth": 100 },
"linter": {
"enabled": true,
"rules": { "preset": "recommended" },
"domains": { "react": "recommended" }
},
"javascript": { "formatter": { "quoteStyle": "double" } },
"assist": { "enabled": true, "actions": { "source": { "organizeImports": "on" } } }
}
css
@import "tailwindcss";
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--radius: 0.5rem;
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
}
The @import "tailwindcss"; line is load-bearing: the @tailwindcss/vite plugin alone produces no styles without it - a missing import is the classic "Tailwind renders nothing" footgun. Use @theme inline (not plain @theme) for tokens that reference CSS variables, so they track dark-mode changes.
Plain function, ref as a regular prop (no forwardRef), native element props via React.ComponentProps, variants via CVA, data-slot for styling hooks, and no manual memoization - the compiler handles it.
tsx
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
outline: "border border-input bg-background hover:bg-accent",
ghost: "hover:bg-accent hover:text-accent-foreground",
},
size: { default: "h-9 px-4 py-2", sm: "h-8 px-3", lg: "h-10 px-8" },
},
defaultVariants: { variant: "default", size: "default" },
}
)
function Button({
className,
variant,
size,
ref,
...props
}: React.ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
return (
<button
ref={ref}
data-slot="button"
className={cn(buttonVariants({ variant, size }), className)}
{...props}
/>
)
}
Note the cn() order: defaults first, consumer className last, so tailwind-merge's last-wins resolution lets callers override.
useMemo/ useCallback for the rare case where you need a value to be a stable effect dependency.{ status: "loading" } | { status: "error"; error }) so impossible states are unrepresentable.React.ComponentProps<"el"> instead of re-declaring HTML attributes by hand.use() over useContext() - it works after early returns and inside conditionals.bg-* with the matching text-*-foreground.biome check --write is your one local command; biome ci in pipelines.codeSplitting (see vite.md).babel-plugin-react-compiler, @biomejs/biome) to avoid surprise diffs between releases.VITE_-prefixed env vars reach browser code via import.meta.env.vite.config.ts - Vitest reuses your build config, so tests see the same aliases and transforms; vitest run in CI, jsdom for component tests.