Leadership

Optimizing Path Management in Vite and Remix with vite.config

As projects grow, import paths become a mess. Deeply nested relative paths like ../../../components/Button slow you down and make refactoring painful.

4 May 2024

Optimizing Path Management in Vite and Remix with vite.config

As projects grow, import paths become a mess. Deeply nested relative paths like ../../../components/Button slow you down and make refactoring painful.

I started using vite.config path aliases on a Remix project and it immediately cleaned things up. One config change, and every file in the project gets clean, predictable imports.

The trade-off is a small upfront cost: you configure aliases once, and you keep tsconfig.json in sync. But the payoff in readability and refactoring speed is worth it every time.

The real problem: two resolvers, one project

An alias has to satisfy two different tools that both resolve imports:

  1. TypeScript, for type-checking and editor navigation.
  2. Vite (which Remix builds on), for actually bundling the code at dev and build time.

If only TypeScript knows about ~/components/Button, your editor is happy but the build fails. If only Vite knows, the build works but tsc and your IDE light up red. The whole trick is keeping the two in agreement, ideally from a single source of truth.

Option 1: one source of truth with tsconfig paths

Define the aliases once in tsconfig.json, then let Vite read them. Start with TypeScript:

Json
{
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "~/*": ["./app/*"],
      "@components/*": ["./app/components/*"],
      "@lib/*": ["./app/lib/*"]
    }
  }
}

Then teach Vite the same map with vite-tsconfig-paths, so you never repeat yourself:

Typescript
import { vitePlugin as remix } from "@remix-run/dev";
import tsconfigPaths from "vite-tsconfig-paths";
import { defineConfig } from "vite";

export default defineConfig({
  plugins: [remix(), tsconfigPaths()],
});

Now import { Button } from "@components/Button" resolves the same way in the editor, in tsc, and in the bundle. Change an alias in one place and everything follows.

Recent Vite versions can read tsconfig paths natively, without the plugin:

Typescript
export default defineConfig({
  resolve: { tsconfigPaths: true },
});

If your Vite version supports it, prefer that and drop the extra dependency.

Option 2: explicit aliases in vite.config

Sometimes you want the alias defined in Vite directly, for example when the target is not a real tsconfig path or you want full control. Use resolve.alias:

Typescript
import { defineConfig } from "vite";
import { fileURLToPath, URL } from "node:url";

export default defineConfig({
  resolve: {
    alias: {
      "~": fileURLToPath(new URL("./app", import.meta.url)),
      "@components": fileURLToPath(new URL("./app/components", import.meta.url)),
    },
  },
});

fileURLToPath(new URL(...)) gives you an absolute path that behaves the same on macOS, Linux, and Windows. If you go this route you still add the matching paths block to tsconfig.json, because Vite aliases do nothing for the type-checker. That duplication is exactly why Option 1 is usually better.

Making Vitest agree

Vitest reads your vite.config, so if you used tsconfigPaths() or resolve.alias there, your tests resolve aliases for free. If your test setup uses a separate config, mirror the aliases under test:

Typescript
export default defineConfig({
  test: {
    alias: { "~": fileURLToPath(new URL("./app", import.meta.url)) },
  },
});

Making ESLint agree

eslint-plugin-import reports aliased imports as unresolved unless you point it at TypeScript's resolver:

Javascript
// eslint config
settings: {
  "import/resolver": {
    typescript: { project: "./tsconfig.json" },
  },
},

Install eslint-import-resolver-typescript and import/no-unresolved reads your paths too. That is the fourth and final tool that needs to share the same map: bundler, type-checker, test runner, linter.

Conventions and gotchas

  • Pick one prefix and stick to it. ~/ for the app root is the Remix convention, @/ is common in Vite and Vue projects. Consistency matters more than the symbol.
  • Watch case sensitivity. macOS is case-insensitive, Linux CI is not. @components/button that works locally can fail the build on CI if the folder is Button.
  • Do not alias over node_modules. Aliases are for your own source. Aliasing a package name leads to confusing resolution bugs.
  • Keep baseUrl set. TypeScript paths are resolved relative to baseUrl. Forget it and every alias silently fails to resolve.

Wrapping up

Path aliases are a small config change with an outsized payoff: readable imports and refactors that do not rewrite a hundred ../../.. strings. The one rule that keeps it from biting you is a single source of truth. Define the map once, in tsconfig.json, and make Vite, Vitest, and ESLint all read from it. Do that and moving a file becomes a non-event.

Go further
Live cohort on Maven

From Senior to Staff: Master the Architecture Skills That Get You Promoted

Go from shaky in design reviews to the engineer everyone trusts to architect the hard stuff.

View the live cohort

Keep reading