Building a Modern App With React, Vite, and Tailwind CSS: The Complete 2025 Guide

React for the UI, Vite for the build tooling, Tailwind for the styles. This is the stack most new frontend projects start with in 2025. This guide wires all three together from scratch and builds a real application structure you can actually start a production project from.

React for the UI. Vite for the build tooling. Tailwind for the styles.

This is the stack most new frontend projects start with. Not because someone published a hot take about it. Teams used it, the codebases stayed maintainable, and the pattern spread. React gives you a component model. Vite gives you a dev server that starts in under two seconds. Tailwind keeps your styles co-located and your CSS files from becoming a graveyard.

Before going further: if you need the foundational understanding of any of these tools first, read the individual articles:

This article assumes you know the basics and want to see how they wire together into something you can actually ship.

Setting Up the Stack

npm create vite@latest my-app -- --template react-ts
cd my-app

Add Tailwind:

npm install -D tailwindcss @tailwindcss/vite

Update vite.config.ts:

import { defineConfig } from 'vite';
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';

export default defineConfig({
  plugins: [
    tailwindcss(),
    react(),
  ],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
  },
});

Update src/index.css:

@import "tailwindcss";

Delete src/App.css. You won't need it. Clean out src/App.tsx to a blank component. Run npm run dev. You should see Vite's dev server start in under two seconds. That's your stack running.

If you'd rather use Bun for faster package installs, replace npm with bun throughout. bun install is considerably faster than npm install for dependency resolution and it works with Vite without any changes.

Project Structure for a Real Application

Don't put everything in src/. Organize it to scale.

src/
  components/
    ui/              # Primitive components: Button, Input, Card, Badge
    layout/          # App shell components: Header, Sidebar, PageContainer
  features/          # Domain-specific code
    auth/
    dashboard/
    users/
  hooks/             # Shared custom hooks
  lib/               # API client, utilities, helpers
  stores/            # Zustand stores
  types/             # Shared TypeScript types
  App.tsx
  main.tsx

Features own everything for their domain: components, hooks, local state, API calls. They import from components/ui/ and lib/. They don't import from each other.

The @ alias you configured in Vite lets you write import { Button } from '@/components/ui/Button' from anywhere without counting ../../.

Building the UI Layer

Start with primitive components. Everything else composes from these.

Button

import { ButtonHTMLAttributes } from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'destructive';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  loading?: boolean;
}

const variantClasses: Record<ButtonVariant, string> = {
  primary:     'bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500',
  secondary:   'bg-gray-100 text-gray-900 hover:bg-gray-200 focus:ring-gray-500',
  ghost:       'text-gray-700 hover:bg-gray-100 focus:ring-gray-500',
  destructive: 'bg-red-600 text-white hover:bg-red-700 focus:ring-red-500',
};

const sizeClasses: Record<ButtonSize, string> = {
  sm: 'px-3 py-1.5 text-sm',
  md: 'px-4 py-2 text-sm',
  lg: 'px-6 py-3 text-base',
};

export function Button({
  variant = 'primary',
  size = 'md',
  loading = false,
  disabled,
  children,
  className = '',
  ...props
}: ButtonProps) {
  return (
    <button
      {...props}
      disabled={disabled || loading}
      className={`
        inline-flex items-center justify-center font-medium rounded-lg
        transition-colors duration-200
        focus:outline-none focus:ring-2 focus:ring-offset-2
        disabled:opacity-50 disabled:cursor-not-allowed
        ${variantClasses[variant]}
        ${sizeClasses[size]}
        ${className}
      `}
    >
      {loading && <span className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />}
      {children}
    </button>
  );
}

The variant and size maps keep the Tailwind classes organized. ButtonHTMLAttributes passes through all native button attributes without listing them manually. The className prop merges so callers can override styles.

Input

import { InputHTMLAttributes, forwardRef } from 'react';

interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
  label?: string;
  error?: string;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
  ({ label, error, className = '', id, ...props }, ref) => {
    return (
      <div className="space-y-1">
        {label && (
          <label htmlFor={id} className="block text-sm font-medium text-gray-700">
            {label}
          </label>
        )}
        <input
          ref={ref}
          id={id}
          className={`
            block w-full rounded-lg border px-3 py-2 text-sm
            placeholder:text-gray-400
            focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent
            disabled:bg-gray-50 disabled:text-gray-500
            ${error ? 'border-red-500' : 'border-gray-300'}
            ${className}
          `}
          {...props}
        />
        {error && <p className="text-sm text-red-600">{error}</p>}
      </div>
    );
  }
);

Input.displayName = 'Input';

forwardRef lets parent components get a ref to the underlying input. Form libraries like React Hook Form require this. Build it in from the start.

Card

import { HTMLAttributes } from 'react';

interface CardProps extends HTMLAttributes<HTMLDivElement> {}

export function Card({ children, className = '', ...props }: CardProps) {
  return (
    <div
      className={`bg-white rounded-xl border border-gray-200 shadow-sm ${className}`}
      {...props}
    >
      {children}
    </div>
  );
}

export function CardHeader({ children, className = '' }: { children: React.ReactNode; className?: string }) {
  return (
    <div className={`px-6 py-4 border-b border-gray-200 ${className}`}>
      {children}
    </div>
  );
}

export function CardContent({ children, className = '' }: { children: React.ReactNode; className?: string }) {
  return (
    <div className={`px-6 py-4 ${className}`}>
      {children}
    </div>
  );
}

Application Layout

import { ReactNode } from 'react';
import { Sidebar } from './Sidebar';
import { Header } from './Header';

export function AppLayout({ children }: { children: ReactNode }) {
  return (
    <div className="min-h-screen bg-gray-50">
      <Sidebar />
      <div className="pl-64">
        <Header />
        <main className="p-8">
          {children}
        </main>
      </div>
    </div>
  );
}

NavLink from React Router accepts a function for className that receives the active state. Tailwind classes swap based on that boolean. No extra state management, no CSS classes toggled manually.

Routing

npm install react-router-dom
import { BrowserRouter, Routes, Route } from 'react-router-dom';
import { AppLayout } from '@/components/layout/AppLayout';
import { DashboardPage } from '@/features/dashboard/DashboardPage';
import { UsersPage } from '@/features/users/UsersPage';

export default function App() {
  return (
    <BrowserRouter>
      <AppLayout>
        <Routes>
          <Route path="/" element={<DashboardPage />} />
          <Route path="/users" element={<UsersPage />} />
        </Routes>
      </AppLayout>
    </BrowserRouter>
  );
}

Every page gets the sidebar and header automatically. Add protected route logic around AppLayout once you have authentication wired up.

State Management

npm install zustand

An auth store:

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface AuthState {
  user: User | null;
  token: string | null;
  login: (user: User, token: string) => void;
  logout: () => void;
}

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      login: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null }),
    }),
    { name: 'auth' }
  )
);

The persist middleware syncs the store to localStorage. Users stay logged in across refreshes. Use it in any component:

function Header() {
  const { user, logout } = useAuthStore();

  return (
    <header className="h-16 bg-white border-b border-gray-200 px-8 flex items-center justify-between">
      <h1 className="text-lg font-semibold text-gray-900">Dashboard</h1>
      <div className="flex items-center gap-4">
        <span className="text-sm text-gray-500">{user?.name}</span>
        <Button variant="ghost" size="sm" onClick={logout}>Sign Out</Button>
      </div>
    </header>
  );
}

API Integration and Server State

A typed API client:

const BASE_URL = import.meta.env.VITE_API_URL;

async function request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
  const stored = localStorage.getItem('auth');
  const token = stored ? JSON.parse(stored).state.token : null;

  const response = await fetch(`${BASE_URL}${endpoint}`, {
    headers: {
      'Content-Type': 'application/json',
      ...(token ? { Authorization: `Bearer ${token}` } : {}),
    },
    ...options,
  });

  if (!response.ok) {
    const error = await response.json().catch(() => ({}));
    throw new Error(error.message ?? `HTTP ${response.status}`);
  }

  return response.json();
}

export const api = {
  get:    <T>(endpoint: string)              => request<T>(endpoint),
  post:   <T>(endpoint: string, data: unknown) => request<T>(endpoint, { method: 'POST',   body: JSON.stringify(data) }),
  put:    <T>(endpoint: string, data: unknown) => request<T>(endpoint, { method: 'PUT',    body: JSON.stringify(data) }),
  delete: <T>(endpoint: string)              => request<T>(endpoint, { method: 'DELETE' }),
};

Pair it with TanStack Query for data fetching:

npm install @tanstack/react-query
// src/features/users/hooks/useUsers.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';

interface User {
  id: string;
  name: string;
  email: string;
}

export function useUsers() {
  return useQuery({
    queryKey: ['users'],
    queryFn: () => api.get<User[]>('/users'),
  });
}

export function useDeleteUser() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: (id: string) => api.delete(`/users/${id}`),
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ['users'] }),
  });
}

Wire the QueryClient into main.tsx:

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      retry: 1,
    },
  },
});

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>
);

Users Table

This is what the stack looks like when you build something real with it:

import { useUsers, useDeleteUser } from './hooks/useUsers';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';

export function UsersPage() {
  const { data: users, isLoading, error } = useUsers();
  const deleteUser = useDeleteUser();

  if (isLoading) {
    return (
      <div className="flex items-center justify-center h-64">
        <div className="text-gray-500 text-sm">Loading users...</div>
      </div>
    );
  }

  if (error) {
    return (
      <div className="rounded-lg bg-red-50 border border-red-200 p-4 text-red-700 text-sm">
        Failed to load users.
      </div>
    );
  }

  return (
    <div className="space-y-6">
      <div className="flex items-center justify-between">
        <h2 className="text-2xl font-bold text-gray-900">Users</h2>
        <Button>Add User</Button>
      </div>

      <Card>
        <table className="w-full text-sm">
          <thead>
            <tr className="border-b border-gray-200 bg-gray-50">
              <th className="px-6 py-3 text-left font-medium text-gray-500">Name</th>
              <th className="px-6 py-3 text-left font-medium text-gray-500">Email</th>
              <th className="px-6 py-3 text-right font-medium text-gray-500">Actions</th>
            </tr>
          </thead>
          <tbody className="divide-y divide-gray-100">
            {users?.map((user) => (
              <tr key={user.id} className="hover:bg-gray-50 transition-colors">
                <td className="px-6 py-4 font-medium text-gray-900">{user.name}</td>
                <td className="px-6 py-4 text-gray-500">{user.email}</td>
                <td className="px-6 py-4 text-right">
                  <Button
                    variant="destructive"
                    size="sm"
                    loading={deleteUser.isPending}
                    onClick={() => deleteUser.mutate(user.id)}
                  >
                    Delete
                  </Button>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </Card>
    </div>
  );
}

Vite serves this fast. React handles rendering and state. Tailwind styles every element in the JSX. TanStack Query manages loading states, errors, and cache invalidation. The Button component handles the loading spinner automatically. You wrote almost none of that infrastructure.

TypeScript Configuration

Tighten the default TypeScript config for production:

{
  "compilerOptions": {
    "strict": true,
    "noUncheckedIndexedAccess": true,
    "noImplicitReturns": true,
    "exactOptionalPropertyTypes": true,
    "target": "ES2022",
    "useDefineForClassFields": true,
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx",
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["src"]
}

noUncheckedIndexedAccess is the flag most people skip. Accessing array[0] returns Type | undefined instead of Type. Annoying for about a day. Catches real bugs after that. The JavaScript vs TypeScript article makes the case for TypeScript in full. Use it from the start.

Production Build and Bundle Analysis

npm install -D rollup-plugin-visualizer

Add to vite.config.ts:

import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    tailwindcss(),
    react(),
    visualizer({ open: true }),
  ],
  // ...
});

Run npm run build. Vite opens a treemap of your bundle in the browser. Find what's large. If a dependency you barely use takes 300kb, that's a decision to make before launch.

Tailwind v4 ships only the CSS classes you use. A full production application typically produces 20-50kb of CSS, compressed.

Environment Setup

# .env.development
VITE_API_URL=http://localhost:8080

# .env.production
VITE_API_URL=https://api.yourapp.com

dist/ is a static site. Deploy it anywhere: Vercel, Netlify, Cloudflare Pages, an S3 bucket behind CloudFront.

Configure your server or CDN to return index.html for any path that doesn't match a file. React Router handles client-side routing; without this, a direct request to /users/123 returns a 404 instead of loading the app.

Nginx config:

location / {
  try_files $uri $uri/ /index.html;
}

The Package List

A production-ready app on this stack uses:

  • react + react-dom for the UI
  • vite + @vitejs/plugin-react for build tooling
  • tailwindcss + @tailwindcss/vite for styling
  • react-router-dom for routing
  • zustand for client state
  • @tanstack/react-query for server state
  • typescript for type safety

Seven packages plus TypeScript. Add to it as your app needs it.

What to Build Next

This skeleton handles routing, layout, auth state, typed API communication, and a UI component system. From here, add:

  • Form validation with React Hook Form and Zod
  • Protected routes and token refresh
  • Error boundaries for graceful error handling
  • A CI pipeline that runs tsc --noEmit before deploying

For the backend side of this picture, the What is Node.JS article covers running JavaScript on the server. If you're deciding which runtime to use for that backend, the Node.js vs Deno vs Bun comparison lays out the tradeoffs without the marketing.

The stack works. The tooling is fast. Ship something.