Frontend Tooling — Vite, Tailwind, TanStack Router

Status: Active Last Updated: 2026-09-01 Category: Web — Tooling Prerequisites: HTML, CSS, Browser JS Tags: vite, tailwind, tanstack-router, devtools, build, hmr

Summary

Modern frontend development uses a build tool (Vite), a styling system (Tailwind), and a router (TanStack Router in this project). This article covers how these tools work, why they're useful, and how they fit together.

What You'll Learn

Table of Contents

  1. Architectural Overview & Core Schema
  2. Deep Dive & Implementation
  3. Anti-Patterns & Common Pitfalls
  4. Independent Challenge
  5. Consolidation & Key Invariants
  6. Next Steps
  7. Change Log

1. Architectural Overview & Core Schema

Foundational theory, system mechanics, and high-level design.

Vite — Build Tool

Vite is the build tool used by this project (vite.config.ts in repo root). It provides:

bun run dev      # start dev server
bun run build    # production build
bun run preview  # preview production build locally

Vite's dev server is fast because it serves source files directly to the browser and only transforms them on demand. Production builds use Rollup for tree-shaking and minification.

Why Vite, Not Webpack

Webpack, the dominant bundler of the 2010s, works by bundling your entire application upfront before serving it. As projects grow, this cold-start time becomes painful — a large React app can take 10-30 seconds to start the dev server. Vite takes a fundamentally different approach: instead of bundling at startup, it serves individual ES modules (ESM) directly to the browser. The browser requests files on demand, and Vite transforms them on the server side (TypeScript to JavaScript, JSX to function calls, CSS imports resolved) only when requested.

This means cold-start time is essentially instant regardless of project size — Vite only transforms the files the browser actually asks for. The second key advantage is Hot Module Replacement: when you change a file, Vite transforms only that module and sends a HMR update to the browser, which replaces just that module in place without reloading the page or losing application state. Webpack's HMR requires rebuilding the changed module in the context of the full bundle, which is slower.

Vite also uses Rollup for production builds, which produces smaller and faster bundles than Webpack's default output through advanced tree-shaking — removing dead code paths based on actual import graphs rather than heuristics.

Configuration and Plugins

Vite is configured in vite.config.ts (or vite.config.js) at the project root. The config lets you define the development server port and proxy rules, control build output, add plugins for frameworks and integrations, and set environment variable handling. The config uses TypeScript and exports either a plain object or an async function for dynamic configuration.

One of the most important features is the dev server proxy. In development, your frontend runs on localhost:5173 while your API runs on localhost:3000. Browsers block cross-origin requests from a frontend server to a different backend port without CORS headers. Instead of requiring the backend to set CORS headers (which would be a security risk in production), you configure Vite to proxy API requests: requests to /api in the browser are forwarded transparently to the backend, which sees them as same-origin requests.

// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
    plugins: [react()],

    // Development server settings
    server: {
        port: 5173,
        // Proxy API requests to backend to avoid CORS issues
        proxy: {
            "/api": {
                target: "http://localhost:3000",
                changeOrigin: true,
                // Rewrite path if backend uses different prefix
                // rewrite: (path) => path.replace(/^\/api/, "")
            },
            "/ws": {
                target: "ws://localhost:3000",
                ws: true // WebSocket support
            }
        }
    },

    // Production build settings
    build: {
        target: "esnext", // Modern JS for smaller bundles
        sourcemap: true,  // Include source maps for debugging
        rollupOptions: {
            output: {
                // Manual chunk splitting for caching
                manualChunks: {
                    vendor: ["react", "react-dom"],
                    router: ["@tanstack/react-router"]
                }
            }
        }
    },

    // Environment variables
    // VITE_API_URL=https://api.example.com
    // Access as import.meta.env.VITE_API_URL
});

Environment Variables and Modes

Vite loads .env files for different environments and modes. VITE_ prefix is required for client-side variables (everything else is server-only and stripped from the client bundle). Files are loaded in order: .env, then .env.local, then .env.[mode], then .env.[mode].local, with later files overriding earlier ones. Use .env.development and .env.production for environment-specific settings, and .env.local for personal overrides that won't be committed to version control.

You can also create mode-specific files like .env.staging and run vite build --mode staging to load that configuration. This is useful for testing staging environments or feature branches that need different API endpoints.

# .env (base defaults, committed to git)
VITE_APP_TITLE="My App"
VITE_API_TIMEOUT=5000

# .env.development (only loaded in dev mode)
VITE_API_URL="http://localhost:3000"
VITE_DEBUG=true

# .env.production (only loaded during production build)
VITE_API_URL="https://api.production.com"
VITE_DEBUG=false

# .env.local (never committed, local overrides)
VITE_API_URL="http://localhost:4000"  # Use a different backend port while developing
// Access in code
console.log(import.meta.env.VITE_APP_TITLE); // "My App"
console.log(import.meta.env.VITE_API_URL);   // depends on mode

// Type your env vars for autocomplete
// src/vite-env.d.ts
/// <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;
}

2. Deep Dive & Implementation

Technical implementation, fully commented code blocks, and step-by-step logic.

Guided Checkpoint

Inspect the Vite build output and Tailwind configuration:

# Check the build output size
ls -lh dist/assets/*.js | head -5

# Verify Tailwind is purging unused CSS — the final CSS should be tiny
wc -c dist/assets/*.css

# Check that HMR is active: run bun run dev, change a CSS class,
# and confirm the browser reflects the change without reloading

# Verify Vite's dev proxy — open browser DevTools Network tab,
# fetch /api/anything — it should show localhost:5173 as the origin

Tailwind CSS

Tailwind is a utility-first CSS framework. Instead of writing custom CSS, you apply pre-built classes:

<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
    Click me
</button>

Each class does one thing:

Tailwind's build step (tailwind.config.ts) scans your source and generates only the CSS you use. Final CSS is small, even with thousands of available classes.

Why Utility-First

Traditional CSS naming conventions like BEM (Block Element Modifier) aim to create reusable, meaningful class names, but they break down in large applications: class names become long and cryptic, you end up writing one-off overrides that bypass the system, and developers interpret the naming scheme differently. CSS-in-JS solutions solve the naming problem but add runtime overhead and complicate debugging.

Utility-first CSS takes a different approach: instead of semantic component classes, you build components inline from small, single-purpose utilities. The "semantic" naming happens in your component structure (the HTML element itself) rather than in the class name. This gives you:

<!-- Responsive card component -->
<div class="max-w-sm rounded-xl bg-white shadow-lg overflow-hidden
            sm:max-w-md md:max-w-lg lg:flex">
    <div class="lg:shrink-0">
        <img class="h-48 w-full object-cover lg:h-full lg:w-48"
             src="/photo.jpg" alt="Description" />
    </div>
    <div class="p-8">
        <div class="uppercase tracking-wide text-sm text-indigo-500
                    font-semibold">Category</div>
        <h2 class="block mt-1 text-lg leading-tight font-medium
                    text-black hover:underline">Title</h2>
        <p class="mt-2 text-slate-500">Description text here.</p>
        <button class="mt-4 px-4 py-2 bg-indigo-600 text-white text-sm
                       font-medium rounded hover:bg-indigo-700
                       focus:outline-none focus:ring-2 focus:ring-indigo-500
                       focus:ring-offset-2 transition-colors">
            Action
        </button>
    </div>
</div>

Configuration and Customization

tailwind.config.ts defines your design tokens: colors, spacing, breakpoints, fonts, and more. Rather than starting from scratch, you extend the default theme with your brand colors, custom spacing scales, or additional font families. The config also controls which files Tailwind scans for class names — by default it looks at .tsx, .ts, .jsx, .js files in your src directory. If your project uses a different file structure, update the content array.

The @apply directive lets you compose frequently-used utility combinations into a custom class name in your CSS file. This is useful for complex patterns you want to reuse without repeating the full list of utilities everywhere.

// tailwind.config.ts
import type { Config } from "tailwindcss";

export default {
    content: [
        "./index.html",
        "./src/**/*.{js,ts,jsx,tsx}"  // Scan these files for class names
    ],
    theme: {
        extend: {
            // Custom brand colors
            colors: {
                brand: {
                    50: "#f0f9ff",
                    500: "#0ea5e9",
                    900: "#0c4a6e"
                }
            },
            // Custom spacing
            spacing: {
                "18": "4.5rem",
                "88": "22rem"
            },
            // Custom font family
            fontFamily: {
                sans: ["Inter", "system-ui", "sans-serif"],
                mono: ["JetBrains Mono", "monospace"]
            },
            // Custom breakpoint
            screens: {
                "3xl": "1920px"
            },
            // Custom animation
            animation: {
                "spin-slow": "spin 3s linear infinite"
            }
        }
    },
    plugins: [
        require("@tailwindcss/forms"),  // Better form styles
        require("@tailwindcss/typography") // Prose styles for markdown
    ]
} satisfies Config;
/* src/index.css */
@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
    /* Reusable button styles using @apply */
    .btn-primary {
        @apply px-4 py-2 bg-indigo-600 text-white font-medium rounded
               hover:bg-indigo-700 transition-colors duration-200;
    }

    .btn-secondary {
        @apply px-4 py-2 bg-slate-100 text-slate-700 font-medium rounded
               hover:bg-slate-200 transition-colors duration-200;
    }

    /* Card component */
    .card {
        @apply bg-white rounded-xl shadow-md p-6 border border-slate-200;
    }
}

Dark Mode

Tailwind supports dark mode via a class strategy (adds dark: variants based on a .dark class on a parent element) or a media strategy (uses @media (prefers-color-scheme: dark)). The class strategy is more flexible because you can toggle dark mode via JavaScript and respect user preferences independently of the OS setting.

To use the class strategy, add darkMode: "class" to your config. Then place a .dark class on the <html> element to enable dark styles. You can read the user's preference from localStorage on page load and toggle the class accordingly, providing a manual override while still respecting system preferences by default.

// tailwind.config.ts
export default {
    darkMode: "class", // Enable class-based dark mode
    // ...
};
// Toggle dark mode and persist preference
function toggleDarkMode() {
    const isDark = document.documentElement.classList.toggle("dark");
    localStorage.setItem("theme", isDark ? "dark" : "light");
}

// Initialize on page load
function initTheme() {
    const stored = localStorage.getItem("theme");
    if (stored === "dark" ||
        (!stored && window.matchMedia("(prefers-color-scheme: dark)").matches)) {
        document.documentElement.classList.add("dark");
    }
}

initTheme();
<!-- Component with dark mode variants -->
<div class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100
            border-slate-200 dark:border-slate-700">
    <h1 class="text-xl font-bold text-slate-900 dark:text-white">
        Heading
    </h1>
    <p class="text-slate-600 dark:text-slate-400">
        This text adapts to dark mode.
    </p>
    <button class="bg-indigo-600 hover:bg-indigo-500
                    dark:bg-indigo-500 dark:hover:bg-indigo-400
                    text-white px-4 py-2 rounded-lg">
        Action
    </button>
</div>

TanStack Router

This project uses TanStack Router for client-side routing. Routes are defined in a file-based structure:

src/routes/
├── index.tsx           # /
├── posts/
│   ├── index.tsx       # /posts
│   └── $postId.tsx     # /posts/:postId
└── admin/
    └── index.tsx       # /admin

Each file exports a component. The router generates type-safe navigation based on these definitions.

Why TanStack Router

Traditional React routers (React Router v6, Wouter) pass route parameters as strings, which you then need to cast and validate. This creates a fragile contract between the route definition and the component that consumes the parameters. If you rename a parameter in the route, TypeScript won't catch usages in other files — the errors only appear at runtime.

TanStack Router generates route types from your file structure at build time, creating a bidirectional link between route paths and their parameters. When you navigate to /posts/42, the component receives a postId with type string (from the URL) that you can pass to API calls. When you generate a link with <Link to="/posts/$postId" params={{ postId: 42 }}>, TanStack validates that postId exists on the route and that the value matches any constraints. Rename a parameter, and every usage is flagged at compile time.

TanStack Router also handles code splitting automatically — each route is a separate chunk that loads on demand, keeping the initial bundle small. It supports nested layouts, search params, route loading states, pending states, and error boundaries as first-class concepts rather than afterthoughts.

Route Files and Layouts

Route files export a component (a default export) and optionally a route definition (a named export). The route's loader function runs before the component renders and can fetch data. The beforeLoad function runs logic (like authentication checks) before navigation proceeds. Layout routes use the FileRoutes component to render child routes, creating nested layouts.

The file-based routing convention maps file paths to URLs, and $ prefixes dynamic segments. Files named index are the default (empty) child of a directory. Files named _ prefix segments that don't contribute to the URL (useful for layouts that wrap a group of routes without adding a URL segment).

// src/routes/posts/$postId.tsx
import { createFileRoute, Link } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";

// Type-safe route definition
export const Route = createFileRoute("/posts/$postId")({
    // Data loader — runs before component renders
    loader: async ({ params }) => {
        const post = await fetchPost(params.postId);
        return { post };
    },

    // Component receives typed params and loader data
    component: PostDetail,
    errorComponent: () => <div>Post not found</div>
});

function PostDetail() {
    const { post } = Route.useLoaderData();
    const navigate = Route.useNavigate();

    return (
        <article>
            <h1>{post.title}</h1>
            <p>{post.content}</p>

            {/* Type-safe navigation */}
            <Link to="/posts/$postId" params={{ postId: post.id }}>
                View post
            </Link>

            {/* Programmatic navigation */}
            <button onClick={() => navigate({ to: "/posts" })}>
                Back to list
            </button>
        </article>
    );
}

Linking and Navigation

The <Link> component renders an anchor tag that performs client-side navigation without a full page reload. Unlike <a href>, it uses the router's history API to update the URL and render the new route in-place. This keeps the application state intact, gives instant navigation, and provides the appearance of a fast, app-like experience.

Key Link props: to is the route path (with $ params), params provides the dynamic values, search sets query string parameters, hash sets the URL hash, and className/other props pass through to the underlying <a> tag. The activeProps and inactiveProps props style the link differently when the current URL matches the link's destination.

// Navigation links
<Link to="/">Home</Link>
<Link to="/posts">All posts</Link>
<Link to="/posts/$postId" params={{ postId: post.id }}>View</Link>
<Link to="/posts" search={{ page: 2, sort: "date" }}>Page 2</Link>
<Link to="/posts/$postId#comments" params={{ postId: 42 }}>Comments</Link>

// Active state styling
<Link
    to="/posts"
    activeProps={{ className: "text-blue-600 font-bold" }}
    inactiveProps={{ className: "text-slate-500 hover:text-slate-700" }}
>
    Posts
</Link>

// programmatic navigation with hooks
function LogoutButton() {
    const navigate = useNavigate();

    async function handleLogout() {
        await api.logout();
        // Redirect to home after logout
        navigate({ to: "/", replace: true });
    }

    return <button onClick={handleLogout}>Log out</button>;
}

Search Parameters and Filters

TanStack Router treats search parameters as first-class state, providing the useSearch hook to read and set them. This is ideal for filters, pagination, and sort options — the URL becomes shareable and bookmarkable with the exact filter state preserved. The search schema (using Zod) validates and parses search params, providing type-safe defaults and transformations.

// src/routes/posts/index.tsx
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";

// Define search param schema
const PostSearchSchema = z.object({
    page: z.number().min(1).default(1),
    limit: z.number().min(1).max(100).default(10),
    sort: z.enum(["date", "title", "views"]).default("date"),
    filter: z.string().optional()
});

export const Route = createFileRoute("/posts")({
    validateSearch: PostSearchSchema,
    loader: async ({ search }) => {
        return fetchPosts({
            page: search.page,
            limit: search.limit,
            sort: search.sort,
            filter: search.filter
        });
    },
    component: PostList
});

function PostList() {
    const { page, limit, sort, filter } = Route.useSearch();
    const setSearch = Route.useNavigate();
    const { posts, total } = Route.useLoaderData();

    function updateSearch(updates: Partial<typeof search>) {
        setSearch({ search: { ...search, ...updates, page: 1 } });
    }

    return (
        <div>
            {/* Filters */}
            <input
                type="text"
                value={filter || ""}
                onChange={(e) => updateSearch({ filter: e.target.value })}
                placeholder="Filter posts..."
            />
            <select
                value={sort}
                onChange={(e) => updateSearch({ sort: e.target.value })}
            >
                <option value="date">Date</option>
                <option value="title">Title</option>
                <option value="views">Views</option>
            </select>

            {/* Post list */}
            {posts.map(post => (
                <Link
                    key={post.id}
                    to="/posts/$postId"
                    params={{ postId: post.id }}
                >
                    {post.title}
                </Link>
            ))}

            {/* Pagination */}
            <div className="flex gap-2">
                <button
                    disabled={page <= 1}
                    onClick={() => setSearch({ search: { ...search, page: page - 1 } })}
                >
                    Previous
                </button>
                <span>Page {page} of {Math.ceil(total / limit)}</span>
                <button
                    disabled={page * limit >= total}
                    onClick={() => setSearch({ search: { ...search, page: page + 1 } })}
                >
                    Next
                </button>
            </div>
        </div>
    );
}

Chrome DevTools

Chrome DevTools is a suite of debugging and profiling tools built into Chrome (and Edge, which uses the same engine). Learning it well dramatically speeds up front-end development by letting you inspect the DOM, debug JavaScript, profile performance, analyze network requests, and audit accessibility.

Elements Panel

The Elements panel shows the live DOM tree. You can click any element to select it, then inspect and modify its attributes, styles, and content in real time. Changes made here are temporary — they disappear on reload — but they're invaluable for experimenting with styling and layout before committing changes to code.

The Styles pane shows the CSS cascade for the selected element, with each rule's source file and line number. You can toggle any property, add new properties, and see computed values. The Computed pane shows the final resolved values after all cascade rules are applied. The Event Listeners pane shows all event listeners attached to the element and its ancestors, with links to the source code.

// In the console, interact with the selected element
$0;          // The currently selected element in Elements panel
$1;          // Previously selected element
$("css");    // querySelector shorthand: $("nav") = document.querySelector("nav")
$$("css");   // querySelectorAll shorthand: returns array
inspect(fn); // Inspect a DOM node or function in Elements panel

// Copy element to clipboard
copy($0);    // Copies the outerHTML of selected element

// Monitor events
monitorEvents($0);       // Log all events on selected element
monitorEvents($0, "click"); // Only log click events
unmonitorEvents($0);     // Stop monitoring

Console Panel

The console is not just console.log. It supports formatting, filtering, and interactive APIs. console.log prints basic values, console.error and console.warn get distinct styling and can trigger error counts in the Issues tab. console.table displays arrays and objects in a sortable table format that's far more readable than the default log output. console.time and console.timeEnd measure elapsed time between calls.

Group related logs with console.group() and console.groupEnd() to create collapsible sections. Use console.assert() to log only when a condition is false. The $0 reference in the console refers to the currently selected element in the Elements panel, letting you inspect and manipulate it interactively.

// Basic logging with string interpolation
const user = { name: "Alice", role: "admin" };
console.log("User:", user.name, "is", user.role);
console.log(`User ${user.name} is ${user.role}`);

// Formatted output
console.log("%cStyled text", "color: blue; font-size: 16px");
console.log("Object: %o", user);  // %o for objects

// Table view (great for arrays of objects)
const users = [
    { name: "Alice", role: "admin" },
    { name: "Bob", role: "editor" },
    { name: "Carol", role: "viewer" }
];
console.table(users);

// Timing
console.time("api-call");
await fetch("/api/data");
console.timeEnd("api-call"); // Prints "api-call: 234ms"

// Grouping
console.group("User Actions");
console.log("Login");
console.log("View dashboard");
console.log("Logout");
console.groupEnd();

// Conditional log
const count = 0;
console.assert(count > 0, "Count should be positive, got:", count);
// Output: Assertion failed: Count should be positive, got: 0

Network Panel

The Network panel records all HTTP requests made by the page. Each row shows the request method, URL, status code, response size, and time. Click a row to see the detailed view: Headers (request and response headers, query parameters), Payload (POST body data), Preview and Response (formatted or raw response body), Timing (detailed breakdown of DNS, TLS, waiting, and download times).

Use the filter bar to narrow down requests by name, type (XHR/fetch, JS, CSS, IMG), or status. The Preserve Log checkbox keeps recordings across page reloads, which is essential for debugging redirect chains or session-related requests. Disable Cache simulates a user with an empty cache, useful for testing resource loading in production conditions.

// In the Network panel, right-click a request and choose:
// - "Copy as cURL" — export as a curl command for replay in terminal
// - "Copy link address" — copy the full URL
// - "Block request URL" — simulate a failing request

// In the console, use the Network panel's filtering programmatically:
const requests = performance.getEntriesByType("resource");
requests.forEach(r => console.log(r.name, r.duration.toFixed(2), "ms"));

// Measure custom resource timing
const mark = performance.mark("my-operation");
// ... do something ...
performance.measure("operation duration", "my-operation-start", "my-operation-end");

Sources Panel and Breakpoints

The Sources panel shows the source code for every file loaded by the page. You can set breakpoints (click the line number) to pause execution and inspect variables in the Scope pane. Beyond basic line breakpoints, you can set conditional breakpoints (right-click a line number, set condition expression), XHR/fetch breakpoints (pause when a specific URL is requested), and DOM mutation breakpoints (pause when an element's children or attributes change).

The Watch pane lets you enter expressions that are re-evaluated on every pause. The Call Stack pane shows the current execution chain. When paused at a breakpoint, you can use the console to run arbitrary JavaScript in the current scope — all variables in the paused function are accessible.

// Programmatic breakpoints (use sparingly in production code)
// debugger; // Activates debugger only when DevTools is open

// Break on all errors
// In Sources panel: check "Pause on caught exceptions"
// Or in console: getEventListeners(window).error[0]?.pause()

// In code, log a stack trace
function trace(msg) {
    console.trace(`Trace: ${msg}`);
}
trace("checkpoint");

// Monitor function calls
// Right-click function in Sources panel > "Function calls" (Blackbox)
// Or use console.count() for call counts
function calculate() { console.count("calculate"); }

Performance and Lighthouse

The Performance panel records a timeline of everything the browser does: scripting, rendering, painting, and layout. Record a user interaction (click, scroll, form submission), stop the recording, and analyze the flame chart to find bottlenecks. Look for long bars in the Main thread track — these indicate heavy JavaScript execution that blocks the UI.

The Lighthouse panel runs a battery of automated audits against the current page, scoring Performance, Accessibility, Best Practices, and SEO. Run it against the production build (not dev server) for accurate results. Core Web Vitals scores (LCP, FID, CLS) are included. Use Lighthouse to catch common issues: unoptimized images, render-blocking scripts, missing accessibility attributes, and poor color contrast.

// In the Performance panel, use User Timing marks
performance.mark("render-start");

// Mark a span (shows as a colored region in the timeline)
performance.mark("fetch-start");
await fetchData();
performance.mark("fetch-end");
performance.measure("fetch", "fetch-start", "fetch-end");

// View in console
const measures = performance.getEntriesByType("measure");
measures.forEach(m => console.log(`${m.name}: ${m.duration.toFixed(2)}ms`));

// Core Web Vitals in code (also available in Lighthouse)
// These use the web-vitals library or the native PerformanceObserver API
new PerformanceObserver((list) => {
    for (const entry of list.getEntries()) {
        console.log(`${entry.name}: ${entry.value.toFixed(2)}`);
    }
}).observe({ type: "largest-contentful-paint", buffered: true });

3. Anti-Patterns & Common Pitfalls

Documented failure modes and how to detect/prevent them.

Over-relying on !important to override Tailwind utilities

Tailwind's utility classes have high specificity (they use class selectors). Developers who add !important to force their overrides end up with !important everywhere, defeating the purpose of the utility framework. The cascade becomes unpredictable.

Fix: Leverage Tailwind's own override mechanism. Use more specific selectors, wrap in a component class, or use Tailwind's @layer and @apply patterns. If you need to override a single utility, use a more-specific context:

<!-- WRONG — fighting Tailwind with !important -->
<div class="bg-red-500 !important">

<!-- RIGHT — more specific container overrides -->
<div class="card bg-red-500"> <!-- .card is a custom component class -->

Forgetting the Vite dev proxy (CORS in development)

During development, the frontend runs on localhost:5173 and the API on localhost:3000. A fetch('/api/data') from the browser triggers CORS checks. Forgetting to configure server.proxy in vite.config.ts means you either disable CORS on the server (a production security risk) or get silent failures in the browser.

Fix: Always configure the dev proxy:

// vite.config.ts
server: {
  proxy: {
    '/api': { target: 'http://localhost:3000', changeOrigin: true }
  }
}

In production, the same-origin proxy is handled by the deployment platform (nginx, API gateway).

Committing .env.local to version control

.env.local contains personal overrides and possibly secrets. Committing it exposes credentials and makes per-developer configuration impossible.

Fix: Add .env.local to .gitignore (it's in the default Vite gitignore). Use .env for shared defaults and .env.development / .env.production for environment-specific settings.

Shipping sourcemaps in production builds

Sourcemaps (sourcemap: true in build.rollupOptions) are large files that expose your full source code in the browser's DevTools. Even if not "public," they are trivially accessible via the Network tab in production.

Fix: Generate sourcemaps only in development or use a separate source map upload to an error-tracking service (Sentry, Bugsnag). In production:

build: { sourcemap: false } // or 'hidden' for error trackers only

Using import.meta.env.VITE_* variables in server-side code

VITE_-prefixed variables are embedded in the client bundle at build time. They are not available in Node.js server code and are visible in the browser bundle (no secrets). Using them for API keys or database URLs that should stay server-side is a security hole.

Fix: Server-only secrets go in .env files read by the server (never in .env files with the VITE_ prefix). The client should never hold server credentials.

Not cleaning up TanStack Router loaders when navigating away

Route loaders fetch data when a route activates. If navigation happens before a loader completes, the component may receive stale data or mount with incomplete state. On rapid navigation (e.g., clicking between menu items quickly), race conditions can display the wrong route's data.

Fix: Use AbortController inside loaders to cancel in-flight fetches when the route unmounts. TanStack Router's beforeLoad can run auth checks before data fetching begins. Always handle the pending state in your component to show loading UI.

4. Independent Challenge

A problem to solve without step-by-step guidance; promotes synthesis.

Configure a complete Vite + Tailwind + TanStack Router project from scratch. Requirements:

  1. Initialize with bun create vite (choose vanilla TypeScript)
  2. Add Tailwind CSS with bun add tailwindcss @tailwindcss/vite and configure it
  3. Add two routes: / (home) and /posts (list of posts)
  4. On /posts, fetch mock data from /api/posts — use the Vite dev proxy to forward to http://localhost:3001/api/posts (serve a static db.json via bunx json-server --port 3001 db.json)
  5. Configure dark mode with class strategy and a toggle button in a <nav>
  6. Add an environment variable VITE_APP_TITLE and display it in the page title
  7. Use Chrome DevTools Performance panel to confirm HMR updates apply without a full reload

Constraints: No component frameworks (no React/Vue). Vanilla TS + Vite + Tailwind only.

5. Consolidation & Key Invariants

Bullet-list summary of the must-remember rules.

6. Next Steps

Sequenced links to dependent lessons, deeper dives, or production guides.

Now that you understand the core frontend tooling, explore these related topics:


Change Log

Choose Theme

Your selection is saved locally.

Neural Cacophony
Aperture v2
Flux v1
Mosaic Chaos
Nexus v1
Nexus Zest
Prism v2
Synapse