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

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:
- TypeScript, for type-checking and editor navigation.
- 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:
{
"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:
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:
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:
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:
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:
// 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/buttonthat works locally can fail the build on CI if the folder isButton. - Do not alias over node_modules. Aliases are for your own source. Aliasing a package name leads to confusing resolution bugs.
- Keep
baseUrlset. TypeScriptpathsare resolved relative tobaseUrl. 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.
AI Transformation for Organisational Leaders
For directors, heads of function and founders: eight live evening sessions with Ehsan Gazar and Anoosh Zeighami, ending with a one-page 2027 AI plan for your organisation, built on a real workflow and ready for your leadership team.
$1,495, or $1,045 with code EARLYBIRD until 23 Oct.
View the live cohortKeep reading
- Your AI Pilot Saved Time. Why Didn't the Business Change?
- A Chatbot Answers. An Agent Does the Work. Here Is the Difference
- Why AI Can Crack a 90-Year-Old Maths Problem and Still Get Your Invoice Wrong
- I Use AI Every Day: Why I Built a Place to Practise the Old Problems
- How I Find Problems Worth Solving
- Notes on Software Quality