Vite 6 + React + TypeScript: Build a Modern Web App from Scratch in 2026

Vite 6 redefines frontend development. With its new Environment API, even faster HMR, and a mature plugin ecosystem, Vite 6 has become the de facto standard for React + TypeScript projects in 2026. In this guide, you will build a complete application while exploring every key feature of Vite 6.
What You Will Learn
By the end of this guide, you will know how to:
- Create and configure a Vite 6 + React + TypeScript project from scratch
- Understand and use the new Environment API in Vite 6
- Configure Hot Module Replacement (HMR) for ultra-fast development
- Optimize production bundling with tree-shaking and code splitting
- Write and run tests with Vitest
- Configure environment variables and API proxies
- Add modern CSS with CSS Modules and PostCSS
- Deploy your application to production
Prerequisites
Before starting, make sure you have:
- Node.js 20+ installed (or Bun 1.1+)
- Basic knowledge of React (components, hooks, JSX)
- Familiarity with TypeScript (types, interfaces, generics)
- A code editor — VS Code or Cursor recommended
- A terminal with access to npm or pnpm
Why Vite 6?
Since its first release in 2020, Vite has revolutionized frontend development by replacing heavy bundlers like webpack with a dev server based on native ES modules. Vite 6, released in late 2025, brings major improvements:
| Feature | Vite 5 | Vite 6 |
|---|---|---|
| Environment API | Absent | Complete and stable |
| Dev server | Fast | Even faster |
| Rolldown support | Experimental | Stable |
| Module resolution | Standard | Optimized |
| TypeScript config | Good | Excellent with autocompletion |
The main highlight of Vite 6 is the Environment API — an abstraction that lets you manage multiple runtime environments (client, SSR, worklets) in a single project with distinct configurations.
Step 1: Create the Project
Let us scaffold a new project with the official React + TypeScript template:
npm create vite@latest my-vite-app -- --template react-ts
cd my-vite-app
npm installIf you prefer pnpm (recommended for performance):
pnpm create vite my-vite-app --template react-ts
cd my-vite-app
pnpm installProject Structure
Here is the generated structure:
my-vite-app/
├── public/
│ └── vite.svg
├── src/
│ ├── assets/
│ │ └── react.svg
│ ├── App.css
│ ├── App.tsx
│ ├── index.css
│ ├── main.tsx
│ └── vite-env.d.ts
├── index.html
├── package.json
├── tsconfig.json
├── tsconfig.app.json
├── tsconfig.node.json
└── vite.config.ts
Unlike traditional bundlers, index.html is at the project root — Vite uses it as the entry point rather than a JavaScript file.
Start the dev server:
npm run devOpen http://localhost:5173 — you will see the default React app with HMR enabled. Modify src/App.tsx and watch changes appear instantly in the browser.
Step 2: Understanding Vite 6 Configuration
Open vite.config.ts — the heart of your configuration:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
})Advanced Configuration
Let us enhance this configuration for a production project:
import { defineConfig, loadEnv } from 'vite'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), '')
return {
plugins: [react()],
// Path aliases for clean imports
resolve: {
alias: {
'@': resolve(__dirname, './src'),
'@components': resolve(__dirname, './src/components'),
'@hooks': resolve(__dirname, './src/hooks'),
'@utils': resolve(__dirname, './src/utils'),
'@types': resolve(__dirname, './src/types'),
},
},
// Dev server configuration
server: {
port: 3000,
open: true,
// Proxy API calls in development
proxy: {
'/api': {
target: env.VITE_API_URL || 'http://localhost:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ''),
},
},
},
// Production build configuration
build: {
target: 'es2022',
sourcemap: true,
rollupOptions: {
output: {
manualChunks: {
vendor: ['react', 'react-dom'],
},
},
},
},
// CSS configuration
css: {
modules: {
localsConvention: 'camelCase',
},
},
}
})Configure TypeScript Aliases
For TypeScript to recognize the @ aliases, update tsconfig.app.json:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@hooks/*": ["./src/hooks/*"],
"@utils/*": ["./src/utils/*"],
"@types/*": ["./src/types/*"]
}
}
}Step 3: The Vite 6 Environment API
The flagship feature of Vite 6 is the Environment API. Before Vite 6, the dev server processed all code in a single pipeline. Now, each environment (client, SSR, worker) has its own configuration and module graph.
Why It Matters
Imagine a project with server-side rendering (SSR). Before Vite 6, the same pipeline transformed both client and server code, which could cause conflicts. With the Environment API:
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
environments: {
// Client environment (browser)
client: {
build: {
outDir: 'dist/client',
rollupOptions: {
input: './index.html',
},
},
},
// SSR environment (Node.js)
ssr: {
build: {
outDir: 'dist/server',
ssr: true,
},
resolve: {
conditions: ['node'],
},
},
},
})Usage in Plugins
If you develop Vite plugins, the Environment API lets you target a specific environment:
function myVitePlugin() {
return {
name: 'my-plugin',
// This hook runs in each environment
resolveId(source, importer, options) {
if (this.environment.name === 'ssr') {
// SSR-specific logic
return null
}
// Default logic (client)
return null
},
// Configure differently per environment
configureServer(server) {
const clientEnv = server.environments.client
const ssrEnv = server.environments.ssr
// Each environment has its own module graph
console.log('Client modules:', clientEnv.moduleGraph.idToModuleMap.size)
},
}
}For most classic React projects (SPA without SSR), the Environment API works automatically with the default client environment. Its main benefit shows in full-stack frameworks like Next.js, Remix, or Astro that use Vite internally.
Step 4: Structuring the React Project
Let us create a scalable project architecture:
mkdir -p src/{components,hooks,utils,types,pages,styles,services}Main Layout
Create src/components/Layout.tsx:
import { Outlet } from 'react-router-dom'
import { Header } from './Header'
import { Footer } from './Footer'
import styles from './Layout.module.css'
export function Layout() {
return (
<div className={styles.layout}>
<Header />
<main className={styles.main}>
<Outlet />
</main>
<Footer />
</div>
)
}CSS Modules with Vite
Create src/components/Layout.module.css — Vite supports CSS Modules natively:
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main {
flex: 1;
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
width: 100%;
}Header Component
Create src/components/Header.tsx:
import { Link, NavLink } from 'react-router-dom'
import styles from './Header.module.css'
const navLinks = [
{ to: '/', label: 'Home' },
{ to: '/about', label: 'About' },
{ to: '/dashboard', label: 'Dashboard' },
]
export function Header() {
return (
<header className={styles.header}>
<Link to="/" className={styles.logo}>
My Vite App
</Link>
<nav className={styles.nav}>
{navLinks.map(({ to, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
isActive ? styles.active : styles.link
}
>
{label}
</NavLink>
))}
</nav>
</header>
)
}Footer Component
Create src/components/Footer.tsx:
import styles from './Footer.module.css'
export function Footer() {
return (
<footer className={styles.footer}>
<p>
Built with Vite 6 + React + TypeScript —{' '}
{new Date().getFullYear()}
</p>
</footer>
)
}Step 5: Routing with React Router
Install React Router:
npm install react-router-domCreate src/pages/Home.tsx:
import styles from './Home.module.css'
export function Home() {
return (
<div className={styles.home}>
<h1>Welcome to My Vite 6 App</h1>
<p>
This application is built with Vite 6, React 19, and TypeScript
for optimal performance.
</p>
<div className={styles.features}>
<FeatureCard
title="Lightning-fast HMR"
description="Changes reflected instantly in the browser"
/>
<FeatureCard
title="Native TypeScript"
description="Full TypeScript support with zero configuration"
/>
<FeatureCard
title="Optimized Build"
description="Automatic tree-shaking and code splitting"
/>
</div>
</div>
)
}
function FeatureCard({ title, description }: { title: string; description: string }) {
return (
<div className={styles.card}>
<h3>{title}</h3>
<p>{description}</p>
</div>
)
}Update src/main.tsx to configure routing:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
import { Layout } from '@/components/Layout'
import { Home } from '@/pages/Home'
import './index.css'
const router = createBrowserRouter([
{
element: <Layout />,
children: [
{ index: true, element: <Home /> },
{
path: 'about',
lazy: () => import('@/pages/About').then(m => ({ Component: m.About })),
},
{
path: 'dashboard',
lazy: () => import('@/pages/Dashboard').then(m => ({ Component: m.Dashboard })),
},
],
},
])
createRoot(document.getElementById('root')!).render(
<StrictMode>
<RouterProvider router={router} />
</StrictMode>,
)Notice the use of lazy() for code splitting — the About and Dashboard pages will be loaded on demand, reducing the initial bundle size.
Step 6: Environment Variables
Vite 6 manages environment variables with .env files:
# .env — loaded in all modes
VITE_APP_TITLE=My Vite App
VITE_API_URL=https://api.example.com
# .env.development — dev mode only
VITE_API_URL=http://localhost:8080
VITE_DEBUG=true
# .env.production — production mode only
VITE_API_URL=https://api.production.comImportant: only variables prefixed with VITE_ are exposed to client-side code. Variables without this prefix stay server-side to protect secrets.
Typing Environment Variables
Create src/env.d.ts for TypeScript typing:
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_APP_TITLE: string
readonly VITE_API_URL: string
readonly VITE_DEBUG?: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}Usage in your code:
const apiUrl = import.meta.env.VITE_API_URL
const appTitle = import.meta.env.VITE_APP_TITLE
const isDev = import.meta.env.DEV // automatic booleanStep 7: Data Fetching with a Custom Hook
Create a reusable hook for API calls. Create src/hooks/useFetch.ts:
import { useState, useEffect } from 'react'
interface FetchState<T> {
data: T | null
loading: boolean
error: string | null
}
export function useFetch<T>(url: string): FetchState<T> {
const [state, setState] = useState<FetchState<T>>({
data: null,
loading: true,
error: null,
})
useEffect(() => {
const controller = new AbortController()
async function fetchData() {
try {
setState(prev => ({ ...prev, loading: true, error: null }))
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) {
throw new Error(`HTTP error: ${response.status}`)
}
const data = await response.json()
setState({ data, loading: false, error: null })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') return
setState({
data: null,
loading: false,
error: err instanceof Error ? err.message : 'Unknown error',
})
}
}
fetchData()
return () => controller.abort()
}, [url])
return state
}Dashboard Page with Dynamic Data
Create src/pages/Dashboard.tsx:
import { useFetch } from '@/hooks/useFetch'
import styles from './Dashboard.module.css'
interface User {
id: number
name: string
email: string
}
export function Dashboard() {
const { data: users, loading, error } = useFetch<User[]>(
'https://jsonplaceholder.typicode.com/users'
)
if (loading) return <div className={styles.loading}>Loading...</div>
if (error) return <div className={styles.error}>Error: {error}</div>
return (
<div className={styles.dashboard}>
<h1>Dashboard</h1>
<div className={styles.grid}>
{users?.map(user => (
<div key={user.id} className={styles.userCard}>
<h3>{user.name}</h3>
<p>{user.email}</p>
</div>
))}
</div>
</div>
)
}Step 8: Optimizing the Production Build
Vite 6 uses Rollup (or soon Rolldown) for the production build. Let us configure optimizations:
Advanced Code Splitting
// vite.config.ts
export default defineConfig({
build: {
target: 'es2022',
minify: 'esbuild',
sourcemap: true,
rollupOptions: {
output: {
manualChunks(id) {
// Separate React dependencies into a vendor chunk
if (id.includes('node_modules/react')) {
return 'react-vendor'
}
// Separate React Router
if (id.includes('node_modules/react-router')) {
return 'router'
}
// Group other dependencies
if (id.includes('node_modules')) {
return 'vendor'
}
},
},
},
// Chunk size warning threshold
chunkSizeWarningLimit: 500,
},
})Analyzing Bundle Size
Install the analysis plugin:
npm install -D rollup-plugin-visualizerAdd it to your configuration:
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
}),
],
})Run the build:
npm run buildAn interactive report will open in your browser, showing the size of each module and chunk.
Asset Preloading
Vite 6 automatically generates <link rel="modulepreload"> tags for critical chunks. You can also configure preload manually:
export default defineConfig({
build: {
modulePreload: {
polyfill: true, // Polyfill for older browsers
},
},
})Step 9: Testing with Vitest
Vitest is the natural testing framework for Vite — it shares the same configuration and offers Jest-compatible APIs.
Installation
npm install -D vitest @testing-library/react @testing-library/jest-dom jsdomConfiguration
Add Vitest configuration in vite.config.ts:
/// <reference types="vitest/config" />
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: 'jsdom',
setupFiles: './src/test/setup.ts',
include: ['src/**/*.{test,spec}.{ts,tsx}'],
coverage: {
provider: 'v8',
reporter: ['text', 'json', 'html'],
include: ['src/**/*.{ts,tsx}'],
exclude: ['src/**/*.test.{ts,tsx}', 'src/test/**'],
},
},
})Create src/test/setup.ts:
import '@testing-library/jest-dom'Writing a Test
Create src/hooks/useFetch.test.ts:
import { renderHook, waitFor } from '@testing-library/react'
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useFetch } from './useFetch'
describe('useFetch', () => {
beforeEach(() => {
vi.restoreAllMocks()
})
it('should return data after a successful fetch', async () => {
const mockData = [{ id: 1, name: 'Test' }]
global.fetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(mockData),
})
const { result } = renderHook(() =>
useFetch('https://api.example.com/data')
)
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
expect(result.current.data).toEqual(mockData)
expect(result.current.error).toBeNull()
})
})
it('should handle HTTP errors', async () => {
global.fetch = vi.fn().mockResolvedValue({
ok: false,
status: 404,
})
const { result } = renderHook(() =>
useFetch('https://api.example.com/not-found')
)
await waitFor(() => {
expect(result.current.loading).toBe(false)
expect(result.current.error).toBe('HTTP error: 404')
expect(result.current.data).toBeNull()
})
})
})Testing a Component
Create src/components/Header.test.tsx:
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { describe, it, expect } from 'vitest'
import { Header } from './Header'
describe('Header', () => {
it('should display the logo and navigation links', () => {
render(
<MemoryRouter>
<Header />
</MemoryRouter>
)
expect(screen.getByText('My Vite App')).toBeInTheDocument()
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('About')).toBeInTheDocument()
expect(screen.getByText('Dashboard')).toBeInTheDocument()
})
})Run the tests:
npx vitest # Watch mode
npx vitest run # Single run
npx vitest --coverage # With coverageStep 10: Essential Vite Plugins
SVG as React Components
npm install -D vite-plugin-svgrimport svgr from 'vite-plugin-svgr'
export default defineConfig({
plugins: [
react(),
svgr(),
],
})Use SVGs as components:
import { ReactComponent as Logo } from '@/assets/logo.svg'
function App() {
return <Logo className="logo" />
}Compression Plugin
npm install -D vite-plugin-compressionimport compression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
react(),
compression({
algorithm: 'brotliCompress',
ext: '.br',
}),
compression({
algorithm: 'gzip',
ext: '.gz',
}),
],
})PWA Plugin
npm install -D vite-plugin-pwaimport { VitePWA } from 'vite-plugin-pwa'
export default defineConfig({
plugins: [
react(),
VitePWA({
registerType: 'autoUpdate',
manifest: {
name: 'My Vite App',
short_name: 'MyApp',
theme_color: '#646cff',
},
}),
],
})Step 11: Deploying to Production
Production Build
npm run buildVite generates optimized files in the dist/ folder:
dist/
├── assets/
│ ├── index-[hash].js # Application code
│ ├── react-vendor-[hash].js # React chunk
│ ├── vendor-[hash].js # Other dependencies
│ └── index-[hash].css # Styles
├── index.html
└── vite.svg
Preview the Build
npm run previewThis starts a local server serving the production files — perfect for verifying everything works before deployment.
Deploy to Vercel
npm install -g vercel
vercelVercel automatically detects Vite and configures the build.
Deploy to Cloudflare Pages
npm install -D wrangler
npx wrangler pages deploy distDeploy to a VPS with Nginx
Create an nginx.conf file:
server {
listen 80;
server_name myapp.com;
root /var/www/my-vite-app/dist;
index index.html;
# Gzip compression
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
# Cache static assets (hashed files)
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
}Vite's automatic file name hashing enables aggressive caching — files change names on every modification, so users always get the latest version.
Troubleshooting
HMR Not Working
If changes are not reflected automatically:
- Verify your file exports a React component (React HMR requires component exports)
- Check the browser console for WebSocket errors
- Try restarting the dev server:
npm run dev
"Failed to resolve import" Error
This usually means a misconfigured alias:
- Verify the alias is defined in both
vite.config.tsANDtsconfig.app.json - Restart the dev server after changing configuration
- Verify the imported file path exists
Build Too Large
- Use
rollup-plugin-visualizerto identify large modules - Implement code splitting with
React.lazy()or lazy routing - Verify tree-shaking works — use named imports instead of default imports from large libraries
Next Steps
Now that you have mastered Vite 6 with React and TypeScript, here are some paths to explore:
- State management — integrate Zustand or Jotai for state management
- UI Components — add shadcn/ui for an accessible component library
- Backend — connect your app to an API with tRPC or TanStack Query
- E2E Tests — set up Playwright for end-to-end testing
- CI/CD — build a GitHub Actions pipeline to automate testing and deployment
Conclusion
Vite 6 represents a major milestone in the evolution of JavaScript frontend tooling. With the new Environment API, further improved development performance, and a rich plugin ecosystem, it provides everything you need to build modern, performant React + TypeScript applications.
Key takeaways:
- Vite 6 is fast — instant HMR and optimized builds accelerate your workflow
- The Environment API opens new possibilities for SSR and full-stack frameworks
- Vitest integrates naturally and offers a first-class testing experience
- The plugin ecosystem covers every need: PWA, SVG, compression, analysis
Whether you are starting a new project or migrating from webpack or Create React App, Vite 6 is the obvious choice for frontend development in 2026.
Discuss Your Project with Us
We're here to help with your web development needs. Schedule a call to discuss your project and how we can assist you.
Let's find the best solutions for your needs.
Related Articles

Bun 2.0 Complete Guide: The All-in-One JavaScript Runtime for 2026
Master Bun 2.0 from scratch — the blazing-fast JavaScript runtime that replaces Node.js, npm, webpack, and Jest in a single binary. This hands-on guide covers the runtime, package manager, bundler, test runner, and building a production REST API.

Building a Full-Stack App with TanStack Start: The Next-Generation React Framework
Learn how to build a complete full-stack application with TanStack Start, the React meta-framework powered by TanStack Router and Vite. This tutorial covers file-based routing, server functions, middleware, authentication, and deployment.

Building Local-First Collaborative Apps with Yjs and React
Learn how to build real-time collaborative applications that work offline using Yjs CRDTs and React. This tutorial covers conflict-free data synchronization, offline-first architecture, and building a shared document editor from scratch.