> ## Content Index
> Fetch the complete content index at: https://sudosecurity.org/llms.txt
> Use this file to discover other available public pages before exploring further.

# Building a Modern App With React, Vite, and Tailwind CSS: The Complete 2025 Guide
- URL: https://sudosecurity.org/react-tailwind-modern-app-guide/
- Published: 2025-07-26T00:45:00.000Z
- Updated: 2026-07-17T10:48:13.000Z
- Description: 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.
- Author: Traven
- Tags: Web Development, JavaScript, TypeScript, Frontend Development, HTML/CSS

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:

- [What is React?](https://sudosecurity.org/what-is-react/) covers the component model, state, and hooks
- [What is Vite?](https://sudosecurity.org/what-is-vite) covers the build tool, config, and why it replaced Webpack
- [What is Tailwind CSS?](https://sudosecurity.org/what-is-tailwind-css) covers utility-first styling and responsive design

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

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

```

Add Tailwind:

```bash
npm install -D tailwindcss @tailwindcss/vite

```

Update `vite.config.ts`:

```typescript
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`:

```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](https://sudosecurity.org/what-is-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

```tsx
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

```tsx
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

```tsx
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

```tsx
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

```bash
npm install react-router-dom

```

```tsx
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

```bash
npm install zustand

```

An auth store:

```typescript
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:

```tsx
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:

```typescript
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:

```bash
npm install @tanstack/react-query

```

```typescript
// 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`:

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

```tsx
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:

```json
{
  "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](https://sudosecurity.org/javascript-vs-typescript/) makes the case for TypeScript in full. Use it from the start.

## Production Build and Bundle Analysis

```bash
npm install -D rollup-plugin-visualizer

```

Add to `vite.config.ts`:

```typescript
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

```bash
# .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:

```nginx
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](https://sudosecurity.org/what-is-node-js/) 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](https://sudosecurity.org/node-js-vs-deno-vs-bun) lays out the tradeoffs without the marketing.

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