How to Use package.json imports for Node Aliases

How to Use package.json imports for Node Aliases

Node's package.json imports field is the cleanest way to create small private aliases for Node code that runs without a bundler. Use it for stable internal boundaries like #db, #http/*, or #config, but keep the map short so it explains your project instead of hiding it.

The feature solves a real annoyance: backend code that slowly fills with relative paths like this:

import { query } from '../../../db/index.js';
import { request } from '../../http/client.js';
import { loadConfig } from '../../../config/load.js';

Those imports work, but they are tied to the current file's folder depth. Move a route handler one directory deeper and the import changes even though the dependency did not.

You can solve that with a bundler alias in frontend code. You can also use TypeScript paths for editor and compiler convenience. But if the file runs directly in Node, I want Node itself to understand the path. That is where imports belongs.

Add a Small imports Map

Start in package.json:

{
  "type": "module",
  "imports": {
    "#db": "./src/db/index.js",
    "#http/*": "./src/http/*.js",
    "#config": "./src/config/production.js"
  }
}

Then import through those names:

import { query } from '#db';
import { request } from '#http/client';
import { mode } from '#config';

console.log(query('select 1'));
console.log(request('/health'));
console.log(mode);

The # prefix is not style. Node requires entries in the imports field to start with # so they are not confused with external packages. #db is a Node package import. @/db may be a Vite or Webpack alias, but it is not this feature.

Use Patterns for Stable Folders

Pattern mappings are useful when a folder exposes several small modules:

{
  "imports": {
    "#http/*": "./src/http/*.js"
  }
}

This import:

import { request } from '#http/client';

resolves to:

./src/http/client.js

That is enough for many server apps. You get a stable import name without teaching every file how many ../ segments it needs.

I would still keep patterns narrow. #http/* tells me something. #* tells me almost nothing. If every folder needs an alias, the project probably needs a structure pass before it needs more resolver rules. I covered the organization side in my guide to creating a good folder structure for your Vite app; the same idea applies to Node projects. Aliases should support the folder structure, not compensate for a messy one.

Use Conditions for Small Environment Swaps

Package imports can also use conditions. A practical example is a tiny config module:

{
  "type": "module",
  "imports": {
    "#config": {
      "development": "./src/config/development.js",
      "default": "./src/config/production.js"
    }
  }
}

Your application code stays the same:

import { mode } from '#config';

console.log(mode);

Run normally and Node uses the default target:

node app.js

Run with a custom condition and Node can select the development target:

node --conditions=development app.js

Do not overuse this. I like it for a small internal module boundary, not for replacing normal configuration loading. Keep default as the fallback and keep the branch names obvious.

Do Not Mix Runtime Aliases by Accident

This is where teams trip.

A Vite alias lives in Vite config. A Node package import lives in package.json. TypeScript paths is not automatically a runtime resolver. If the same source file goes through multiple tools, each tool has to understand the specifier before you rely on it.

For frontend code, start with the tool that actually builds the app. My post on how to configure your Vite config file is the better direction for Vite-owned aliases.

For Node scripts, CLIs, server entry points, workers, and background jobs, imports is usually cleaner because Node resolves it directly.

My Rule for Adding an Alias

I use package.json imports when all of this is true:

  • the code runs in Node
  • the alias is private to the current package
  • the target is a stable internal boundary
  • the alias replaces noisy relative paths, not a broken folder layout
  • tests run with the same resolver
  • a new developer can find the mapping in package.json

I avoid it when a normal relative import is clearer.

That last point matters. This is fine:

import { normalizeUser } from './normalize-user.js';

Do not replace it with an alias just because aliases feel cleaner. Local files should usually look local. Reserve # imports for dependencies that act like internal services or shared project boundaries.

A Small Working Shape

A project using this pattern might end up with just three aliases:

{
  "type": "module",
  "imports": {
    "#db": "./src/db/index.js",
    "#http/*": "./src/http/*.js",
    "#config": {
      "development": "./src/config/development.js",
      "default": "./src/config/production.js"
    }
  }
}

That is boring in the best way. #db means database boundary. #http/client means a module inside the HTTP folder. #config means the current config implementation.

The goal is not to make imports look clever. The goal is to make the path stable enough that moving a file does not turn into a pointless import rewrite.

Sources

Stay Sharp. Weekly Insights.
New posts, framework updates and weekly software conversations.

No spam. Unsubscribe anytime.
Author profile picture
Walt is a software engineer, startup founder and previous mentor for a coding bootcamp. He has been creating software for the past 20+ years.
No comments posted yet
// Add a comment
// Color Theme

Custom accent
Pick any color
for the accent