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

Noqta Team
By Noqta Team ·

Loading the Text to Speech Audio Player...

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:

FeatureVite 5Vite 6
Environment APIAbsentComplete and stable
Dev serverFastEven faster
Rolldown supportExperimentalStable
Module resolutionStandardOptimized
TypeScript configGoodExcellent 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 install

If you prefer pnpm (recommended for performance):

pnpm create vite my-vite-app --template react-ts
cd my-vite-app
pnpm install

Project 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 dev

Open 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>
  )
}

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 &mdash;{' '}
        {new Date().getFullYear()}
      </p>
    </footer>
  )
}

Step 5: Routing with React Router

Install React Router:

npm install react-router-dom

Create 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.com

Important: 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 boolean

Step 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-visualizer

Add 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 build

An 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 jsdom

Configuration

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 coverage

Step 10: Essential Vite Plugins

SVG as React Components

npm install -D vite-plugin-svgr
import 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-compression
import 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-pwa
import { 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 build

Vite 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 preview

This starts a local server serving the production files — perfect for verifying everything works before deployment.

Deploy to Vercel

npm install -g vercel
vercel

Vercel automatically detects Vite and configures the build.

Deploy to Cloudflare Pages

npm install -D wrangler
npx wrangler pages deploy dist

Deploy 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:

  1. Verify your file exports a React component (React HMR requires component exports)
  2. Check the browser console for WebSocket errors
  3. Try restarting the dev server: npm run dev

"Failed to resolve import" Error

This usually means a misconfigured alias:

  1. Verify the alias is defined in both vite.config.ts AND tsconfig.app.json
  2. Restart the dev server after changing configuration
  3. Verify the imported file path exists

Build Too Large

  1. Use rollup-plugin-visualizer to identify large modules
  2. Implement code splitting with React.lazy() or lazy routing
  3. 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.


Want to read more tutorials? Check out our latest tutorial on Building REST APIs with Rust and Axum: A Practical Beginner's Guide.

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.

30 min read·

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.

30 min read·