Path Aliases: Clean Imports with @/

Set up and correctly maintain path aliases across TypeScript and Vite so imports stay short, stable, and editor-friendly as a project grows.

As a React project grows past a handful of files, relative imports start to hurt: import Button from "../../../../components/Button" is fragile, hard to read, and breaks the moment you move a file. Path aliases solve this by letting you write import Button from "@/components/Button" instead — a short, absolute-feeling path that never changes no matter where the importing file lives. This lesson covers how to configure aliases correctly in both TypeScript and Vite (they must agree), how editors use this configuration, and the most common ways alias setups break.

The problem aliases solve

Deep relative imports are a maintenance liability. If src/features/checkout/components/OrderSummary.tsx imports ../../../../components/Button, moving OrderSummary.tsx one directory deeper breaks the import silently until you run the app or your linter catches it. It's also simply hard to read: a reviewer has to mentally trace ../../../.. to know what's actually being imported.

An alias like @/components/Button fixes both problems: it reads the same regardless of where the importing file lives, and moving the importing file never requires touching its aliased imports. The @ symbol is a convention (not a language feature) that most React/Vite projects use to mean 'the src/ directory', though any alias name and target can be defined.

Configuring the TypeScript side

TypeScript's compiler needs to know about the alias so that type-checking, 'go to definition', and autocomplete work. This is configured in tsconfig.json (or a base config it extends) via compilerOptions.baseUrl and compilerOptions.paths. paths is a map from alias patterns to real relative locations, resolved against baseUrl.

It is critical to understand that paths in tsconfig.json affects type-checking and editor tooling only — it does not, by itself, make the alias work at runtime or in the actual build. TypeScript compiles @/components/Button through to the exact same string; it never rewrites the import path in emitted JavaScript. That means a second, separate configuration is required for the bundler.

Configuring the Vite side

Vite needs its own alias configuration, in resolve.alias inside vite.config.ts, so that when it actually resolves @/components/Button to a real file on disk — during dev serving or production bundling — it knows where to look. This is a completely separate mechanism from TypeScript's paths; the two configs must be kept in sync manually, because Vite does not read tsconfig.json's paths field automatically.

If only one side is configured, you get one of two broken states: configure only tsconfig and the code type-checks and autocompletes fine but crashes at runtime with a module resolution error, because Vite has no idea what @ means; configure only Vite and the app runs fine in the browser but your editor and tsc report the import as unresolvable, showing red squiggles everywhere despite working code.

Editor support and build consistency

Editor features like autocomplete, 'go to definition', and automatic import insertion all read from the TypeScript language server, which in turn reads tsconfig.json. This is why keeping paths accurate matters even beyond type-checking correctness — it directly determines whether your editor experience feels seamless or constantly wrong.

Build consistency means every tool that touches your code — the dev server, the production bundler, the type-checker, your test runner (e.g. Vitest), and your editor — must resolve @/components/Button to the exact same file. A common source of subtle bugs is a test runner configured separately from Vite (or from an older config format) that doesn't share the same alias resolution, causing tests to fail to import modules that the app itself imports fine.

Common module resolution errors and what they mean

"Cannot find module '@/components/Button' or its corresponding type declarations" from TypeScript/your editor means tsconfig.json's paths is missing, misspelled, or pointed at the wrong baseUrl.

A runtime error like "Failed to resolve import '@/components/Button' from src/App.tsx. Does the file exist?" from the Vite dev server (or a Rollup 'could not resolve' error during vite build) means resolve.alias is missing from vite.config.ts, even though TypeScript may report no error at all.

A test failure saying a module can't be found, while the app itself runs fine, almost always means your test runner's config (e.g. vitest.config.ts or a test block inside vite.config.ts) doesn't share the same resolve.alias — Vitest reuses Vite's config only if it's actually pointed at it.

Matching alias configuration: tsconfig.json + vite.config.ts

The tsconfig "@/*": ["src/*"] wildcard pattern must correspond to the Vite alias "@" -> path.resolve(__dirname, "./src") — both describe the same mapping in each tool's own syntax. If you also run Vitest, either point it at the same vite.config.ts (recommended) or duplicate resolve.alias into vitest.config.ts.

// tsconfig.json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  }
}

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

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

Code Examples

No matter how deeply nested OrderSummary.tsx is inside src/features/checkout/components/, every import reads identically and clearly states what it depends on, without any ../../.. counting.

import Button from "@/components/Button";

This split-brain failure — editor green, app crashing — is the single most common alias bug, because the two configurations are checked independently by different tools and nothing forces them to agree.

Common mistake: Editing tsconfig.json's paths and assuming the app will 'just work' because the editor stopped complaining — always update resolve.alias in vite.config.ts in the same change.

// src/features/checkout/components/OrderSummary.tsx
import Button from "@/components/Button";
import { formatCurrency } from "@/utils/formatCurrency";
import type { Order } from "@/types/order";

export function OrderSummary({ order }: { order: Order }) {
  return (
    <div>
      <p>Total: {formatCurrency(order.totalCents)}</p>
      <Button onClick={order.checkout}>Confirm Order</Button>
    </div>
  );
}
# tsconfig.json has "@/*": ["src/*"], but vite.config.ts has no resolve.alias

$ npm run dev
✘ [ERROR] Failed to resolve import "@/components/Button" from "src/App.tsx".
  Does the file exist?

# Meanwhile, the editor shows no red squiggles at all — TypeScript is happy,
# because it only checks types, never resolves the import at runtime.

Two separate resolvers, one alias, kept in sync manually

The same import string is resolved twice, by two independent tools, using two independent configuration sources that you must keep pointed at the same target.

import "@/components/Button"
           │
           ├──▶ TypeScript / editor  ──reads──▶ tsconfig.json paths ──▶ type-check, autocomplete
           │
           └──▶ Vite dev server / build ──reads──▶ vite.config.ts resolve.alias ──▶ actual file on disk

Important Rules

Every alias defined in tsconfig.json's paths must have a matching entry in vite.config.ts's resolve.alias, and vice versa.

Prefer a single alias root (@/ -> src/) over many narrow aliases unless the project genuinely needs finer-grained boundaries.

Point your test runner's config at the same vite.config.ts (or duplicate the alias) so tests resolve modules identically to the app.

Use @/ for absolute-style imports and reserve relative imports (./ , ../) for closely related files within the same feature folder — this makes it visually obvious which imports are 'nearby' versus 'elsewhere in the app'.

Where alias setup bites teams in practice

Path alias misconfiguration is one of the most frequent 'works on my machine' bugs: a developer adds an alias to tsconfig.json to silence editor errors, everything looks fine locally because the dev server was already running with cached resolution, and it isn't caught until CI runs a fresh build (or a teammate does) and the module resolution error surfaces. Teams that template new projects from a known-good starter, or that lint for consistency between tsconfig.json and vite.config.ts, avoid this class of bug entirely.

Configuring only tsconfig.json's paths and expecting the alias to work when you run the app.

Configure both tsconfig.json paths and vite.config.ts resolve.alias to point at the same location.

TypeScript's paths field is a type-checking/editor-only construct; it never affects what Vite (or Rollup, or Node) actually resolves at runtime.

Letting Vitest use its own default config with no resolve.alias while the app uses Vite's.

Reuse the app's vite.config.ts for Vitest (mergeConfig or a test block inside vite.config.ts), or explicitly duplicate resolve.alias in a separate vitest.config.ts.

Otherwise tests importing anything through @/ fail with module resolution errors even though the same code runs fine in the actual app.

What does adding "@/*": ["src/*"] to tsconfig.json's paths actually accomplish by itself? A. It makes the alias work in the browser at runtime B. It only affects TypeScript type-checking and editor tooling, not runtime module resolution C. It automatically configures Vite to resolve the same alias D. It changes how the production bundle is minified

B. It only affects TypeScript type-checking and editor tooling, not runtime module resolution — tsconfig.json's paths field is consumed by the TypeScript compiler and language server only; a bundler like Vite needs its own separate alias configuration to actually resolve the import at build/runtime.

A component imports "@/components/Button" and the editor shows no errors, but vite build fails with a 'could not resolve' error. What is the most likely cause? A. The component file has a syntax error B. resolve.alias is missing or misconfigured in vite.config.ts even though tsconfig.json's paths is set up correctly C. The Button component doesn't export anything D. Vite doesn't support the @ character in import paths

B. resolve.alias is missing or misconfigured in vite.config.ts even though tsconfig.json's paths is set up correctly — This is the classic split-brain alias bug: TypeScript is satisfied because paths is configured, but Vite has no matching resolve.alias, so it fails to resolve the same import at build time.

Why might tests fail to resolve an @/ import even though the app runs fine with npm run dev? A. Vitest always ignores TypeScript configuration B. The test runner may be using its own configuration that doesn't include the same resolve.alias as vite.config.ts C. Aliases only work in production builds D. Test files cannot import components at all

B. The test runner may be using its own configuration that doesn't include the same resolve.alias as vite.config.ts — If the test runner isn't sharing (or duplicating) Vite's resolve.alias configuration, it resolves modules independently and won't know what @/ means, even though the app and dev server work correctly.

In a fresh Vite + React + TypeScript project, set up an @/ alias pointing at src/, use it to import a component from a deeply nested feature folder, and verify it works in all three contexts: the dev server, a production build, and (if present) the test runner.

Add "paths": { "@/*": ["src/*"] } with "baseUrl": "." to tsconfig.json, add resolve: { alias: { "@": path.resolve(__dirname, "./src") } } to vite.config.ts, then import via @/... and confirm no errors in editor, dev server, production build, or test run.

Update both tsconfig.json's compilerOptions.paths and vite.config.ts's resolve.alias in the same commit, then run npm run dev, npm run build, and npm test to check each independently.