Setting up a React project with Vite

Scaffold with create-vite, understand the folder layout, how Fast Refresh and the automatic JSX transform work, and the TypeScript and lint defaults.

Scaffold and layout

// src/main.tsx - the single entry point of the application
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>
)
PathWhat lives there
index.htmlThe single HTML shell; Vite injects the bundle here
src/main.tsxMounts the app; the only file that touches the DOM directly
src/App.tsxThe root component; real apps mount the router here
src/components/Reusable components, usually one folder each with its styles and tests
src/hooks/Custom hooks shared across features
src/assets/Images and fonts imported by components so the bundler fingerprints them
public/Files copied as-is; referenced by absolute path, never imported

Fast Refresh and the JSX transform

// no React import is needed: the automatic JSX runtime handles it
function Panel({ title, children }) {
  return (
    <section className="panel">
      <h2>{title}</h2>
      {children}
    </section>
  )
}

// editing this file updates the panel in place, keeping its state
export default function App() {
  return (
    <Panel title="Release notes">
      <p>Fast Refresh replaced this component without reloading the page.</p>
    </Panel>
  )
}
  • The automatic runtime is what removes import React from 'react'; the build targets a modern JSX transform, so do not add the old import back.
  • Fast Refresh preserves component state for edits in the module graph. Editing a file that only exports non-components (a constant, a helper) triggers a full page reload instead.
  • Module scope is preserved across refreshes, so a module-level mutable value goes stale in ways component state does not - keep mutable state inside components or a store.
  • Vite serves native ES modules in development, which is why startup is fast and why import paths must include the extension where the resolver requires it.
  • The dev server never type-checks. Add tsc --noEmit to CI, or use the default build script, which runs the checker before bundling.

TypeScript, lint and formatting

// vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'

export default defineConfig({
  plugins: [react()],
  server: { port: 5173, open: true },
  build: { sourcemap: true },
  resolve: { alias: { '@': new URL('./src', import.meta.url).pathname } }
})
  • The react-ts template writes tsconfig.app.json for application code with strict and jsx: "react-jsx", and tsconfig.node.json for the config files that run in Node.
  • Set noUnusedLocals and noUnusedParameters from the start: unused imports accumulate quickly in component files.
  • eslint-plugin-react-hooks is what catches a missing effect dependency and a conditional hook call. Keep it in the pipeline rather than fixing those by review.
  • Run the formatter on save and in a pre-commit hook, so formatting never appears in a functional diff.
  • Add npm run build to CI; it runs the type check and the production bundle together.
⚠️
A green vite build only proves the code bundles, because the tooling strips types without checking them. Run tsc --noEmit as its own step, otherwise a type error can reach production through a passing pipeline.

FAQ

Why does the template create two tsconfig files?
Application code needs the DOM libraries and jsx: react-jsx, while vite.config.ts runs in Node with different globals and no DOM. One file cannot describe both without weakening the checks.
When should I use Next.js instead of Vite?
When you need server rendering, file-based routing or server components. Vite is a client-side build tool: it is lighter, faster to start, and the better default for a dashboard or an internal tool behind a login.

Production builds, environment config and deployment Testing React components

Last refreshed 2026-09-18.