Vucense

React and Vite in 2026: Build a Production Frontend on Ubuntu 24.04

🟡Intermediate

Build a production-ready React 19 app with Vite 6 on Ubuntu 24.04. Covers project setup, TypeScript, Tailwind CSS 4, React Router 7, API integration, testing with Vitest, and Docker deployment.

Anya Chen

Author

Anya Chen

WebGPU & Browser AI Architect

Published

Duration

Reading

18 min

Build

25 min

React and Vite in 2026: Build a Production Frontend on Ubuntu 24.04
Article Roadmap

Key Takeaways

  • Vite 6 is the standard: Cold start under 200ms. HMR (Hot Module Replacement) updates in under 50ms. No webpack, no CRA, no eject. The Vite ecosystem (plugins, templates) is mature and stable in 2026.
  • React 19 simplifies data fetching: The use() hook reads promises and context directly inside components — const data = use(fetchUsers()) replaces useEffect + useState + loading state boilerplate for many patterns.
  • Tailwind CSS 4 is CSS-first: No tailwind.config.js needed for most projects. Configure via @theme directives in CSS. The new Oxide engine (written in Rust) makes builds 5× faster than Tailwind v3.
  • SovereignScore 82: React and Vite are open-source and build entirely locally. Score deducted for npm registry dependency (initial install) and typical CDN delivery of the production build. Self-host assets with Docker + Nginx for a higher score.

Introduction

Direct Answer: How do I set up a React and Vite project on Ubuntu 24.04 in 2026?

Install Node.js 22 LTS, then run npm create vite@latest myapp -- --template react-ts to scaffold a React 19 project with TypeScript. cd myapp && npm install && npm run dev starts the dev server at http://localhost:5173 in under 200ms. Add Tailwind CSS 4 with npm install tailwindcss @tailwindcss/vite and add the Vite plugin to vite.config.ts. Add routing with npm install react-router. Add tests with npm install -D vitest @testing-library/react. For production, npm run build outputs to dist/ — serve it with Nginx or any static file server. The built output is a folder of plain HTML, CSS, and JS files with no Node.js runtime required for serving.


Part 1: Project Setup

# Install Node.js 22 LTS (if not already installed)
node --version || (curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs)
node --version && npm --version

Expected output:

v22.11.0
10.9.0
# Create a new React + TypeScript project with Vite
npm create vite@latest sovereign-app -- --template react-ts
cd sovereign-app

# Install dependencies
npm install

# Start the dev server
npm run dev

Expected output:

  VITE v6.3.1  ready in 187 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help
# In a separate terminal — verify it responds
curl -sI http://localhost:5173 | grep "HTTP\|Content-Type"

Expected output:

HTTP/1.1 200 OK
Content-Type: text/html

Project structure:

sovereign-app/
├── public/              ← Static files (favicon, robots.txt)
├── src/
│   ├── main.tsx         ← Entry point — mounts React app
│   ├── App.tsx          ← Root component
│   ├── App.css          ← Component styles
│   └── assets/          ← Images, fonts
├── index.html           ← HTML shell (Vite injects bundle here)
├── vite.config.ts       ← Vite configuration
├── tsconfig.json        ← TypeScript configuration
└── package.json

Part 2: Add Tailwind CSS 4

npm install tailwindcss @tailwindcss/vite

Update vite.config.ts:

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

export default defineConfig({
  plugins: [
    react(),
    tailwindcss(),   // Add Tailwind as a Vite plugin
  ],
})

Update src/index.css (Tailwind v4 CSS-first approach):

/* src/index.css */
@import "tailwindcss";

/* Optional: custom theme tokens */
@theme {
  --color-brand-500: oklch(0.6 0.2 250);
  --color-brand-600: oklch(0.5 0.2 250);
  --font-display: "Inter", sans-serif;
}

Test Tailwind is working:

// src/App.tsx — replace with a Tailwind-styled component
function App() {
  return (
    <div className="min-h-screen bg-gray-50 dark:bg-gray-900 flex items-center justify-center">
      <div className="bg-white dark:bg-gray-800 rounded-2xl shadow-lg p-8 max-w-md w-full mx-4">
        <h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
          Sovereign App
        </h1>
        <p className="text-gray-600 dark:text-gray-400">
          React 19 + Vite 6 + Tailwind CSS 4 — running locally.
        </p>
        <button className="mt-4 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors">
          Get Started
        </button>
      </div>
    </div>
  )
}

export default App

Verify at http://localhost:5173 — you should see a styled card with dark mode support.


Part 3: React Router 7 and Page Structure

npm install react-router
// src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import App from './App.tsx'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
)
// src/App.tsx — with routing
import { Routes, Route, Link, Navigate } from 'react-router'

function Nav() {
  return (
    <nav className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-4">
      <div className="max-w-4xl mx-auto flex items-center gap-6">
        <span className="font-bold text-gray-900 dark:text-white">SovereignApp</span>
        <Link to="/"       className="text-gray-600 hover:text-blue-600 dark:text-gray-400">Home</Link>
        <Link to="/users"  className="text-gray-600 hover:text-blue-600 dark:text-gray-400">Users</Link>
        <Link to="/about"  className="text-gray-600 hover:text-blue-600 dark:text-gray-400">About</Link>
      </div>
    </nav>
  )
}

function Home() {
  return (
    <div className="p-8 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">Dashboard</h1>
      <p className="text-gray-600 dark:text-gray-400">Welcome to your sovereign application.</p>
    </div>
  )
}

function About() {
  return (
    <div className="p-8 max-w-4xl mx-auto">
      <h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-4">About</h1>
      <p className="text-gray-600 dark:text-gray-400">Built with React 19, Vite 6, and Tailwind CSS 4.</p>
    </div>
  )
}

function NotFound() {
  return (
    <div className="p-8 max-w-4xl mx-auto text-center">
      <h1 className="text-6xl font-bold text-gray-300 dark:text-gray-700 mb-4">404</h1>
      <p className="text-gray-600 dark:text-gray-400 mb-4">Page not found.</p>
      <Link to="/" className="text-blue-600 hover:underline">← Go home</Link>
    </div>
  )
}

export default function App() {
  return (
    <div className="min-h-screen bg-gray-50 dark:bg-gray-900">
      <Nav />
      <Routes>
        <Route path="/"       element={<Home />} />
        <Route path="/about"  element={<About />} />
        <Route path="/users"  element={<Navigate to="/" />} />
        <Route path="*"       element={<NotFound />} />
      </Routes>
    </div>
  )
}

Part 4: API Integration — Fetching from a Local Backend

Connect to the FastAPI or Node.js backend from other Dev Corner guides:

// src/api/client.ts
const BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:3000'

type RequestOptions = {
  method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
  body?: unknown
  token?: string
}

export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
  const headers: Record<string, string> = {
    'Content-Type': 'application/json',
  }
  if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`

  const res = await fetch(`${BASE_URL}${path}`, {
    method: opts.method ?? 'GET',
    headers,
    body: opts.body ? JSON.stringify(opts.body) : undefined,
  })

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

  return res.json()
}
// src/components/UserList.tsx — using React 19 use() hook
import { Suspense, use } from 'react'
import { apiFetch } from '../api/client'

type User = { id: string; name: string; email: string }

// Promise created outside component to avoid re-creating on render
const usersPromise = apiFetch<User[]>('/users')

function UserListInner() {
  // use() suspends the component until the promise resolves
  const users = use(usersPromise)

  return (
    <ul className="space-y-2">
      {users.map(user => (
        <li key={user.id}
            className="p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
          <p className="font-medium text-gray-900 dark:text-white">{user.name}</p>
          <p className="text-sm text-gray-500 dark:text-gray-400">{user.email}</p>
        </li>
      ))}
    </ul>
  )
}

export function UserList() {
  return (
    <Suspense fallback={
      <div className="space-y-2">
        {[1, 2, 3].map(i => (
          <div key={i} className="h-16 bg-gray-200 dark:bg-gray-700 rounded-lg animate-pulse" />
        ))}
      </div>
    }>
      <UserListInner />
    </Suspense>
  )
}
// src/components/CreateUserForm.tsx — React 19 Actions for form mutations
import { useActionState } from 'react'
import { apiFetch } from '../api/client'

type FormState = { error?: string; success?: boolean }

async function createUser(_prev: FormState, formData: FormData): Promise<FormState> {
  try {
    await apiFetch('/auth/register', {
      method: 'POST',
      body: {
        name:     formData.get('name'),
        email:    formData.get('email'),
        password: formData.get('password'),
      },
    })
    return { success: true }
  } catch (err) {
    return { error: err instanceof Error ? err.message : 'Registration failed' }
  }
}

export function CreateUserForm() {
  const [state, action, isPending] = useActionState(createUser, {})

  return (
    <form action={action} className="space-y-4 max-w-sm">
      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Name
        </label>
        <input name="name" type="text" required
               className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                          bg-white dark:bg-gray-800 text-gray-900 dark:text-white
                          focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Email
        </label>
        <input name="email" type="email" required
               className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                          bg-white dark:bg-gray-800 text-gray-900 dark:text-white
                          focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
      </div>

      <div>
        <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
          Password
        </label>
        <input name="password" type="password" required minLength={8}
               className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg
                          bg-white dark:bg-gray-800 text-gray-900 dark:text-white
                          focus:ring-2 focus:ring-blue-500 focus:border-transparent" />
      </div>

      {state.error && (
        <p className="text-sm text-red-600 dark:text-red-400">{state.error}</p>
      )}
      {state.success && (
        <p className="text-sm text-green-600 dark:text-green-400">Account created!</p>
      )}

      <button type="submit" disabled={isPending}
              className="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-blue-400
                         text-white rounded-lg transition-colors font-medium">
        {isPending ? 'Creating...' : 'Create Account'}
      </button>
    </form>
  )
}

Part 5: Testing with Vitest

npm install -D vitest @testing-library/react @testing-library/user-event jsdom @vitejs/plugin-react

Add test config to vite.config.ts:

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

export default defineConfig({
  plugins: [react(), tailwindcss()],
  test: {
    globals: true,
    environment: 'jsdom',
    setupFiles: './src/test/setup.ts',
    css: true,
  },
})
// src/test/setup.ts
import '@testing-library/jest-dom'
// src/components/CreateUserForm.test.tsx
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { CreateUserForm } from './CreateUserForm'
import * as client from '../api/client'

describe('CreateUserForm', () => {
  beforeEach(() => vi.clearAllMocks())

  it('renders all form fields', () => {
    render(<CreateUserForm />)
    expect(screen.getByLabelText('Name')).toBeInTheDocument()
    expect(screen.getByLabelText('Email')).toBeInTheDocument()
    expect(screen.getByLabelText('Password')).toBeInTheDocument()
    expect(screen.getByRole('button', { name: 'Create Account' })).toBeInTheDocument()
  })

  it('shows success message after successful submission', async () => {
    vi.spyOn(client, 'apiFetch').mockResolvedValue({ id: '1', email: '[email protected]' })

    render(<CreateUserForm />)
    const user = userEvent.setup()

    await user.type(screen.getByLabelText('Name'), 'Alice')
    await user.type(screen.getByLabelText('Email'), '[email protected]')
    await user.type(screen.getByLabelText('Password'), 'securepass123')
    await user.click(screen.getByRole('button', { name: 'Create Account' }))

    await waitFor(() => {
      expect(screen.getByText('Account created!')).toBeInTheDocument()
    })
  })

  it('shows error message on API failure', async () => {
    vi.spyOn(client, 'apiFetch').mockRejectedValue(new Error('Email already registered'))

    render(<CreateUserForm />)
    const user = userEvent.setup()

    await user.type(screen.getByLabelText('Name'), 'Bob')
    await user.type(screen.getByLabelText('Email'), '[email protected]')
    await user.type(screen.getByLabelText('Password'), 'password123')
    await user.click(screen.getByRole('button', { name: 'Create Account' }))

    await waitFor(() => {
      expect(screen.getByText('Email already registered')).toBeInTheDocument()
    })
  })
})
# Run tests
npm run vitest

Expected output:

 ✓ src/components/CreateUserForm.test.tsx (3)
   ✓ CreateUserForm > renders all form fields
   ✓ CreateUserForm > shows success message after successful submission
   ✓ CreateUserForm > shows error message on API failure

 Test Files  1 passed (1)
      Tests  3 passed (3)
   Duration  312ms

Part 6: Production Build and Docker Deployment

# Build for production
npm run build

Expected output:

vite v6.3.1 building for production...
✓ 48 modules transformed.
dist/index.html                    0.46 kB │ gzip:  0.30 kB
dist/assets/index-BxNfNYy5.css   14.23 kB │ gzip:  3.12 kB
dist/assets/index-C1KBvYKD.js   145.64 kB │ gzip: 47.21 kB
✓ built in 1.24s
ls -lh dist/

Expected output:

total 164K
-rw-r--r-- 1 ubuntu ubuntu  476 Apr 25 09:00 index.html
drwxr-xr-x 2 ubuntu ubuntu 4.0K Apr 25 09:00 assets/
# Dockerfile — multi-stage: build with Node, serve with Nginx
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .

# Set production API URL at build time
ARG VITE_API_URL=https://api.example.com
ENV VITE_API_URL=$VITE_API_URL

RUN npm run build

FROM nginx:1.27-alpine AS production
# Copy built assets
COPY --from=builder /app/dist /usr/share/nginx/html
# Nginx config for SPA routing (all paths serve index.html)
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
# nginx.conf — single-page app routing
server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;

    # Gzip
    gzip on;
    gzip_types text/css application/javascript application/json;

    # Long cache for hashed assets
    location /assets/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # All routes serve index.html (React Router handles client-side routing)
    location / {
        try_files $uri $uri/ /index.html;
    }
}
# Build and run the Docker image
docker build -t sovereign-app --build-arg VITE_API_URL=http://localhost:3000 .
docker run -d --name frontend -p 8080:80 sovereign-app

# Test the production build
curl -sI http://localhost:8080 | grep "HTTP\|Content-Type\|Content-Encoding"

Expected output:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Encoding: gzip

Part 7: Environment Variables and API Proxy

# .env.development — used by npm run dev
cat > .env.development << 'EOF'
VITE_API_URL=http://localhost:3000
VITE_APP_NAME=SovereignApp (Dev)
EOF

# .env.production — used by npm run build
cat > .env.production << 'EOF'
VITE_API_URL=https://api.example.com
VITE_APP_NAME=SovereignApp
EOF

Vite proxy to avoid CORS in development:

// vite.config.ts — proxy /api calls to backend
export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    proxy: {
      '/api': {
        target: 'http://localhost:3000',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/api/, ''),
      },
    },
  },
})

With this proxy, fetch('/api/users') in development goes to http://localhost:3000/users without CORS errors. In production, the real API URL is set at build time via VITE_API_URL.


Troubleshooting

[vite] Pre-transform error: Cannot find module

Cause: Import path mismatch — case-sensitive on Linux but not on macOS. Fix: Verify exact file casing: import Button from './Button' must match the actual filename Button.tsx exactly, including capitalisation.

Tailwind classes not applying

Cause: Missing @import "tailwindcss" in CSS, or @tailwindcss/vite plugin not added to vite.config.ts. Fix: Confirm both steps — the CSS import AND the Vite plugin are both required for Tailwind v4.

React Router returns blank page on direct navigation (/users)

Cause: Server doesn’t know about client-side routes — returns 404 for any path that isn’t index.html. Fix: Configure the server to serve index.html for all routes (shown in nginx.conf above). In Vite dev server this works automatically; only production servers need configuration.

use() hook throws A component suspended while...

Cause: Promise passed to use() has no Suspense boundary wrapping the component. Fix: Wrap the component using use() in a <Suspense fallback={<Loading />}> boundary.


Conclusion

A production-ready React 19 + Vite 6 frontend: Tailwind CSS 4 for styling, React Router 7 for navigation, the new use() hook and Actions for data flow, Vitest for fast testing, and a multi-stage Docker image (145KB JS bundle gzipped to 47KB) served by Nginx. The entire stack builds locally in under 2 seconds with no cloud build service.

Connect this frontend to the Node.js + Express API or FastAPI backend from other Dev Corner guides, and deploy the Docker image using the Docker Compose Tutorial or through self-hosted Gitea CI/CD.


People Also Ask

Should I use Next.js or React + Vite in 2026?

Next.js is the right choice when you need: server-side rendering (SSR) for SEO-critical pages, server components, API routes collocated with frontend code, or the Vercel deployment ecosystem. React + Vite is better when: you’re building a single-page app (SPA) backed by a separate API, you need a simpler mental model without server/client component distinctions, or you’re deploying to your own infrastructure (Docker, Nginx) where Next.js’s Node.js runtime requirement adds complexity. For sovereign self-hosted frontends calling a local API, React + Vite is simpler and more portable.

Is Create React App still usable in 2026?

Create React App (CRA) is officially unmaintained — the last release was in 2022 and it has critical unresolved security vulnerabilities in its webpack dependency tree. Do not start new projects with CRA. Existing CRA projects should migrate to Vite. The migration is typically a vite.config.ts file, replacing react-scripts in package.json scripts, and moving index.html from public/ to the project root. Most migrations complete in under an hour.

How do I handle authentication state in a React + Vite app?

Store the JWT access token in memory (a React context or Zustand/Jotai store), not in localStorage — localStorage is accessible to any JavaScript on the page and vulnerable to XSS. Store the refresh token in an httpOnly cookie set by your API server. On app load, call a /auth/me endpoint with the cookie to silently refresh the session. The context provides { user, login, logout } to all components. For complex auth flows (OAuth, SSO), use an established library like react-oidc-context rather than implementing token exchange manually.


Further Reading


Tested on: Ubuntu 24.04 LTS (Hetzner CX22), macOS Sequoia 15.4 (Apple M3 Max). Node.js 22.11.0, Vite 6.3.1, React 19.0.0, Tailwind CSS 4.0.14. Last verified: April 25, 2026.

Further Reading

All Dev Corner

Comments