Updated 2026: switched the publish flow from yarn + npm tokens to pnpm + npm trusted publishing (see Footnote 2).

In the past, I have written about making NPM packages with just the typescript compiler (https://cmdcolin.github.io/posts/2022-05-27-youmaynotneedabundler). That post focused on the “CJS” build because it was arguably simpler. This one covers the “ESM” case.

TL;DR, minimal github repo for this post: https://github.com/cmdcolin/minimalpackage

Why pure-ESM now?

I like a ‘bundler-less’ approach to library distribution, which means multiple files end up in dist referencing each other. Pure ESM requires those imports to include the file extension, which used to be awkward, but now we have some new options.

New tsconfig settings

Two new tsc settings, allowImportingTsExtensions and rewriteRelativeImportExtensions, let us import .ts files in src and have tsc rewrite them to .js in dist.

For example:

// src/index.ts
import { bar } from './bar.ts'
export function foo() {
  bar()
}
// src/bar.ts
export function bar() {
  console.log('bar in bar.ts')
}

Running tsc produces:

 ❯❯❯ ll dist
total 24K
-rw-rw-r-- 1 cdiesh cdiesh  37 Jan 14 06:07 bar.d.ts
-rw-rw-r-- 1 cdiesh cdiesh  91 Jan 14 06:07 bar.js
-rw-rw-r-- 1 cdiesh cdiesh  43 Jan 14 06:07 index.d.ts
-rw-rw-r-- 1 cdiesh cdiesh 116 Jan 14 06:07 index.js

dist/index.js now has the rewritten .js import:

import { bar } from './bar.js'
export function foo() {
  bar()
}

and dist/bar.js:

export function bar() {
  console.log('bar in bar.ts')
}

Previously you had to write import { bar } from './bar.js' in src to get this behavior. Now you can reference the actual bar.ts file.

The package.json

{
  "name": "minimalpackage",
  "version": "1.0.0",
  "description": "simple pure-esm package compatible with tsc",
  "license": "MIT",
  "type": "module",
  "exports": "./dist/index.js",
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsc",
    "clean": "rimraf dist",
    "prebuild": "pnpm clean",
    "preversion": "pnpm build",
    "postversion": "git push --follow-tags"
  },
  "devDependencies": {
    "rimraf": "^6.0.1",
    "typescript": "^5.7.3"
  }
}

Notes:

  • I specify both main and exports. exports alone works for consumers on moduleResolution: nodenext, which is still uncommon.

  • No types field is needed. tsc picks it up from the .d.ts next to the entry.

  • exports is often a complex object, but a plain string pointing at a file is enough here.

  • rimraf clears dist before each build. pnpm build --watch passes --watch through to tsc.

  • pnpm version <patch|minor|major> triggers preversion/postversion, which cleans, builds, and pushes the new tag to github. Combined with npm trusted publishing, a GitHub Actions workflow then publishes to NPM without long-lived tokens. See Footnote 2.

The tsconfig.json

{
  "include": ["src"],
  "compilerOptions": {
    "outDir": "dist",
    "target": "es2022",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "declaration": true,
    "strict": true,
    "allowImportingTsExtensions": true,
    "rewriteRelativeImportExtensions": true
  }
}

Using minimalpackage

import { foo } from 'minimalpackage'
foo()

Footnote 1 - Publishing dual ESM/CJS using this technique

If you need dual ESM/CJS, here is a sketch of how to do it with this method: https://gist.github.com/cmdcolin/c3089a4b37f2ff8c8eabce5ebd3b4082

Footnote 2 - Setting up npm trusted publishing

Trusted publishing lets a GitHub Actions workflow publish to NPM via OIDC, so you don’t have to manage an NPM_TOKEN secret.

Configure the trusted publisher for your package, pointing at your GitHub repo and the workflow filename (e.g. publish.yml). This can be done via the npmjs.com web UI under Settings -> Publishing access, or via the npm CLI on v11+.

Then add .github/workflows/publish.yml, triggered on tag pushes (which is what pnpm version produces via postversion):

name: Publish

on:
  push:
    tags:
      - 'v*'

jobs:
  publish:
    runs-on: ubuntu-latest
    permissions:
      id-token: write
      contents: read
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
        with:
          version: 10
      - uses: actions/setup-node@v4
        with:
          node-version: 24.x
          cache: pnpm
          registry-url: 'https://registry.npmjs.org'
      - run: pnpm install --frozen-lockfile
      - run: pnpm build
      - run: npm publish --access public

A few load-bearing details:

  • id-token: write lets npm publish exchange a GitHub OIDC token for an npm publish credential, so no NPM_TOKEN is needed.
  • registry-url in setup-node is what writes the .npmrc used by the OIDC exchange. Don’t drop it.
  • We use npm publish instead of pnpm publish because the OIDC integration lives in the npm CLI.
  • pnpm install --frozen-lockfile assumes a pnpm-lock.yaml is committed.

Full flow: pnpm version patch locally, postversion pushes the tag, and GitHub Actions sees the v* tag and publishes.

Footnote 3 - Add the eslint-plugin-import with rule requiring file extension imports

Add this to your eslint config:

'import/extensions': ['error', 'ignorePackages'],

It will enforce importing from specific files, except for package names, e.g. you need to import from ‘yourfile.ts’ for your code, but can import from packages like normal: import { useState } from 'react'.