When Absolute Path Imports Don't Work: From Cause to Monorepo Setup
1. Why I Encountered This Problem
When I first started learning programming and building toy projects, I didn't worry much about import paths. The file structure was simple, so everything was just one or two folders deep. However, as I started cloning real-world scale projects to study on my own, things got messy.
As I separated components for reusability, gathered utility functions, and managed hooks independently, my folder structure became deeper and deeper. Eventually, the top of my code files looked like a festival of ../../...
import { Button } from '../../../components/ui/Button';
import { useAuth } from '../../../../hooks/useAuth';
import { formatDate } from '../../../utils/date';
import { UserType } from '../../../../types/user';
Looking at this, I thought, "This isn't right." If I moved a file to a different folder, I had to manually update all those paths. Refactoring became pure pain. When I caught myself counting whether there were three or four ../../s, I was convinced there had to be a better way.
That's when I found out about 'Absolute Paths' or 'Path Aliases'. seeing how clean @/components/Button looked, I decided to apply it immediately.
2. The Initial Confusion
Most search results just said, "Add paths configuration to tsconfig.json." Thinking, "Oh, simpler than I thought?", I applied it right away.
// tsconfig.json
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
}
}
After saving and checking VSCode, the red squiggly lines disappeared, and autocomplete worked beautifully. Thrilled to escape the ../../.. hell, I modified my code and saved it.
However, when I actually ran the project, it crashed.
Error: Cannot find module '@/components/ui/Button'
Require stack:
- /Users/me/project/src/pages/index.js
I couldn't understand this situation at all. "Wait, it works fine in VSCode! Cmd+Click takes me to the file perfectly. Why does it claim not to know it only when running?"
My misconception was this: "Use TypeScript, they said. It will automatically transform paths for JS, they said."
But the reality was different. While the editor was happy, the actual runtime environment (Node.js or Browser) had no idea where @/ was pointing to. It was like typing "My Home" into a GPS navigation system, and the system asking back, "Where is that?"
3. The 'Aha!' Moment
After struggling for days, all my questions were answered when I realized that the roles of 'TypeScript' and 'Runtime (or Bundler)' are completely separate.
Comparing this to a "Map" and a "Taxi Driver" made it click instantly.
-
TypeScript (The Map):
- Role: It's a map that verifies "if this path is valid" while you write code.
- Situation: Configuring
pathsintsconfig.jsonis like putting a sticky note on the map saying, "When I say 'My Home', I mean 'Gangnam-gu, Seoul...'". - Result: So VSCode (The Map) knows where
@/componentsis and doesn't show errors.
-
Runtime/Bundler (The Taxi Driver):
- Role: This is the driver who actually executes the code and finds the files.
- Situation: However, that sticky note wasn't passed to the driver (Node.js, Webpack, Vite). The driver cannot read the notes on your map.
- Result: When you tell the driver "Go to 'My Home' (@/components)", they stop driving (Error) asking, "Sir, where on earth is that?"
In the end, I had to leave a note on the map (TS) AND give the address to the driver (Runtime/Bundler) separately.
graph LR
subgraph "Design Time (VSCode)"
A[Source Code] --> B[TypeScript Compiler]
B --> C{tsconfig.json}
C -- Checks paths config --> B
B -- OK --> D[Type Check Pass]
end
subgraph "Runtime / Build Time"
E[Bundler / Node.js] --> F{Config File}
F -- Checks alias config --> E
E --> G[File Resolution]
G -- OK --> H[Execution / Bundle]
end
style C fill:#f9f,stroke:#333
style F fill:#9f9,stroke:#333
4. Deep Dive: History and Environment Configs
Why is this so complicated? Blame the history of JavaScript.
0) History: CommonJS vs ES Modules
For a long time, JS didn't have modules. Then Node.js came with require() (CommonJS/CJS).
Later, browsers adopted import (ES Modules/ESM).
As these two worlds collided, "Module Resolution Strategy" became a mess. TypeScript has to support both, leading to complex configurations to tell TS which environment it's compiling for.
1) Vite + React
Here, Vite is the "Taxi Driver". You need to add configuration to vite.config.ts.
The recommended way is using vite-tsconfig-paths plugin.
// vite.config.ts
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths()], // Just this one line!
});
This makes Vite automatically read paths settings from tsconfig.json.
2) Next.js
Next.js is a really smart driver. If it's configured in tsconfig.json, Next.js's internal Webpack config automatically reads and applies it. So in Next.js, you only need to touch tsconfig.json.
3) Node.js (Express, NestJS, etc.)
This is where I struggled the most. Node.js doesn't have a browser bundler by default. When you compile TypeScript with tsc, JS files are generated, but the import ... from '@/...' code is converted to JS as is.
The cleanest solution is using tsc-alias. It replaces @/ paths with relative paths (../../) in the compiled JS files.
// package.json scripts
"build": "tsc && tsc-alias"
4) Path Issues in Monorepo
In real-world scenarios, you might use a Monorepo. For example, if you want to import a button from packages/ui into apps/web.
// apps/web/src/App.tsx
import { Button } from '@repo/ui/Button'; // usage
You need to align the root tsconfig.json with each package's package.json.
The exports field in package.json is crucial because the "Driver" uses it as the entry point.
// packages/ui/package.json
{
"name": "@repo/ui",
"exports": {
".": "./src/index.ts",
"./*": "./src/*.ts"
}
}
5) Troubleshooting Checklist
Files seem configured, but red lines persist?
- Restart VSCode: Often, the TS Server doesn't pick up
tsconfig.jsonchanges immediately.Cmd + Shift + P->TypeScript: Restart TS Server. - Missing baseUrl:
pathsconfiguration must go withbaseUrl. EnsurebaseUrl: "."is present. - Storybook / Jest: If the main app works but tests fail, remember they are "different drivers." Configure
.storybook/main.jsorjest.config.jsseparately.
5. Recommended Team Conventions
When working in a team, you need rules.
- Stick to
@: Don't mix~,#. Standardize on@/. - Limit Depth:
@/components/common/Buttonis fine.@/components/pages/home/sections/features/Itemis too deep. Keep it flat. - Use Index Imports: Instead of
import { Button } from '@/components/Button/Button', useindex.tsto enableimport { Button } from '@/components'.
6. One-Liner Summary
"If you only mark it on the Map (TypeScript) without entering the address in the Navigation (Runtime), the Taxi Driver cannot find the way."
Recall this sentence when absolute path imports don't work. Remember that configuration for the Editor (TS) and configuration for the Executor (Runtime/Bundler) must be paired, and you will no longer fear the Cannot find module error.