· npm / typescript / publishing

Cách publish npm package đúng chuẩn trong năm 2026

Dual ESM/CJS với tsup, exports map chuẩn, changesets để quản lý version, và provenance — quy trình đầy đủ cho 2026 mà các tutorial xếp hạng cao vẫn còn thiếu.

Bởi

1.699 từ · 9 phút đọc

Tutorial xếp hạng đầu cho từ khóa này được viết từ năm 2023. Nó dạy module.exports, bỏ qua TypeScript hoàn toàn, và ra đời trước khi có npm provenance, exports map, cũng như dual ESM/CJS output. Nếu bạn làm theo ngày hôm nay, bạn sẽ ship một package không có type-safe entry point, không có nguồn gốc chuỗi cung ứng được xác minh, và module resolution bị lỗi trên Node 22+. Bài này hướng dẫn cách publish npm package đúng chuẩn trong 2026.

Bài này dành cho ai

Bạn đang publish một TypeScript library lên npm — một utility package, plugin cho framework, hay thứ gì đó mà người khác sẽ npm install. Bạn muốn nó hoạt động được ở cả dự án ESM lẫn CommonJS, đi kèm với types, và có thể xác minh bằng npm audit signatures. Nếu bạn đang xây dựng một app chứ không phải library, phần lớn nội dung sau không áp dụng cho bạn.

Chuẩn bị

Bước 1 — Cấu hình package.json với exports map chuẩn

Field exports thay thế mainmodule cũ. Nó cho Node biết chính xác file nào cần load theo từng kiểu import, và ngăn consumer deep-import vào các phần internal không công khai.

{
  "name": "my-package",
  "version": "1.0.0",
  "type": "module",
  "files": ["dist"],
  "exports": {
    ".": {
      "types": "./dist/index.d.ts",
      "import": "./dist/index.mjs",
      "require": "./dist/index.cjs"
    }
  }
}

Ba điểm dễ gây lỗi:

types phải đứng đầu tiên. TypeScript duyệt các điều kiện trong exports theo thứ tự. Đặt types ở vị trí khác, TypeScript có thể chọn sai entry point.

Bất kỳ path nào không có trong exports đều ném ERR_PACKAGE_PATH_NOT_EXPORTED. Đây là chủ đích — nó định rõ ranh giới public API. Nếu consumer đang import my-package/src/utils, họ không thể làm vậy nữa. Thêm subpath export nếu bạn muốn công khai nó:

"./utils": {
  "types": "./dist/utils.d.ts",
  "import": "./dist/utils.mjs",
  "require": "./dist/utils.cjs"
}

ESM-only hoàn toàn ổn với Node 22+. Bỏ điều kiện require đi nếu không có consumer nào của bạn dùng CommonJS. Dual output là dành cho tác giả library phải hỗ trợ tooling cũ. Nếu bạn kiểm soát được ai cài package của mình, ESM-only đơn giản hơn và không sai.

Bước 2 — Build dual ESM/CJS với tsup

tsup là con đường ngắn nhất để ra dual output. Cài nó vào:

pnpm add -D tsup

Tạo tsup.config.ts:

import { defineConfig } from 'tsup';

export default defineConfig({
  entry: ['src/index.ts'],
  format: ['esm', 'cjs'],
  dts: true,
  clean: true,
  outExtension({ format }) {
    return format === 'esm' ? { js: '.mjs' } : { js: '.cjs' };
  },
});

Ý nghĩa từng option:

  • format: ['esm', 'cjs'] — emit cả .mjs.cjs
  • dts: true — tạo file declaration *.d.ts kèm theo output
  • clean: true — xóa dist/ trước mỗi lần build để file cũ không còn sót lại
  • outExtension — buộc extension rõ ràng .mjs/.cjs thay vì .js. Bắt buộc khi "type": "module" được đặt trong package.json, vì nếu không Node không phân biệt được ESM và CJS chỉ qua extension

Chạy build:

pnpm tsup

Kết quả:

dist/
  index.mjs      ← ESM
  index.cjs      ← CJS
  index.d.ts     ← TypeScript declarations

Kết quả này khớp trực tiếp với block exports ở Bước 1. Thêm script build vào package.json:

"scripts": {
  "build": "tsup"
}

Ghi chú hướng tới tương lai: tsdown đang được chú ý như một lựa chọn thay thế tsup dựa trên Rolldown. API tương tự, build nhanh hơn. Đáng theo dõi nếu bạn gặp giới hạn hiệu năng của tsup với package lớn, nhưng tsup vẫn là lựa chọn ổn định hiện tại.

Bước 3 — Dùng changesets để quản lý version

Tự tay bump version và sửa CHANGELOG tay sẽ vỡ ở bất kỳ quy mô nào. Changesets gắn việc bump version với từng thay đổi cụ thể.

Cài đặt và khởi tạo:

pnpm add -D @changesets/cli && pnpm changeset init

Lệnh này tạo thư mục .changeset/. Commit nó vào repo. File config được tạo ra hoạt động tốt với giá trị mặc định cho hầu hết các package.

Workflow hàng ngày — chạy lệnh này sau bất kỳ thay đổi nào đáng ship:

pnpm changeset

Nó hỏi package nào thay đổi, loại bump là gì (patch, minor, major), và một dòng tóm tắt. Nó ghi một file markdown vào .changeset/. Commit file đó cùng với code change. Vậy là xong cho đến khi release.

Khi sẵn sàng release:

pnpm changeset version   # đọc tất cả changeset file, bump version, ghi CHANGELOG.md
pnpm changeset publish   # build, sau đó chạy npm publish cho từng package

Với monorepo pnpm + Turborepo có nhiều package, changesets tự động xử lý việc đồng bộ version giữa các package.

Tự động hóa với GitHub Actions — thêm đoạn này vào release workflow:

- name: Create Release Pull Request or Publish to npm
  uses: changesets/action@v1
  with:
    publish: pnpm release
  env:
    NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Thêm "release": "pnpm changeset publish" vào scripts của package.json. Khi có changeset trên main, action sẽ mở một PR “Version Packages”. Merge PR đó là publish tự động. Bạn không cần đụng đến npm publish trực tiếp.

Bước 4 — Xác thực đúng cách

Từ tháng 11 năm 2025, chỉ còn Granular access token được hỗ trợ — legacy access token đã bị loại bỏ (nguồn). Nếu CI của bạn còn đang dùng NPM_TOKEN với classic automation token, nó sẽ ngừng hoạt động — nếu chưa xảy ra thì cũng sắp thôi.

Hai lựa chọn trong 2026:

Lựa chọn A: Granular Access Token (đơn giản hơn)

Vào npmjs.com → Account Settings → Access Tokens → Generate New Token → Granular Access Token.

Chọn các package cụ thể mà token này được phép publish (đừng mở rộng phạm vi hơn cần thiết), đặt ngày hết hạn, và copy token. Thêm vào GitHub Actions secrets với tên NPM_TOKEN. Workflow changesets ở Bước 3 sẽ tự dùng nó.

Granular token giới hạn thiệt hại nếu token bị lộ. Classic token có thể publish hoặc deprecate bất cứ thứ gì trong tài khoản của bạn.

Lựa chọn B: npm Trusted Publishing (không cần token)

Trusted Publishing dùng OIDC để GitHub Actions xác minh danh tính trực tiếp với npm — không cần lưu NPM_TOKEN ở bất cứ đâu.

Yêu cầu: npm CLI ≥ 11.5.1, Node ≥ 22.14.0, và package đã được publish ít nhất một lần (hoặc đã được tạo trên npmjs.com trước lần publish tự động đầu tiên).

Cấu hình tại npmjs.com → package của bạn → Settings → Trusted Publishers → Add Publisher. Nhập tên tổ chức/user GitHub và tên repository. Sau đó trong workflow GitHub Actions, bỏ NPM_TOKEN đi và thêm OIDC permission:

permissions:
  id-token: write
  contents: read

Trusted Publishing cũng tự động tạo provenance — bạn không cần thêm --provenance riêng. Đây là hướng đi mặc định cho 2026 trở đi.

Bước 5 — Publish npm package với provenance

Provenance cho phép consumer chạy npm audit signatures my-package và xác minh rằng package được build từ một commit cụ thể trên một repository cụ thể trong môi trường CI được xác thực. Tính năng này miễn phí cho mọi tài khoản.

Nếu bạn dùng Trusted Publishing (lựa chọn B ở trên), provenance đã tự động. Bỏ qua bước này.

Nếu bạn dùng Granular Access Token (lựa chọn A), thêm --provenance vào lệnh publish:

npm publish --provenance

Hoặc trong script release của package.json:

"release": "pnpm changeset publish --no-git-checks -- --provenance"

Sau khi publish, kiểm tra kết quả:

npm audit signatures my-package

Kết quả khi xác thực thành công:

audited 1 package in 1s

1 package has a verified registry signature

Lần publish đầu tiên — hai điểm cần lưu ý

Scoped package: hãy khai báo rõ ràng ở lần publish đầu. npm v11 mặc định là public access cho package mới, nhưng khai báo tường minh sẽ tránh bất ngờ. Nếu bạn muốn package công khai:

npm publish --access public

Nếu muốn để private (tính năng trả phí), dùng --access restricted.

Lần publish đầu tiên phải dùng token, không phải Trusted Publishing. OIDC Trusted Publishing yêu cầu package đã tồn tại trên registry. Tạo package bằng Granular Access Token trước, sau đó chuyển sang Trusted Publishing cho các lần release tiếp theo.

Lưu ý thêm

pnpm publishnpm publish — cả hai đều chấp nhận --provenance. Cờ --provenance hoạt động giống nhau trên cả hai.

ESM-only hoàn toàn ổn nếu bạn kiểm soát được consumer của mình. Dual output thêm phức tạp không cần thiết. Nếu bạn đang publish một Nuxt plugin, Vite plugin, hay bất cứ thứ gì chỉ nhắm vào Node 22+, bỏ điều kiện require đi. Một format, một entry point.

Lựa chọn thay thế cho tsup — nếu output dựa trên esbuild của tsup xung đột với cấu hình type-checking của bạn (hiếm gặp nhưng xảy ra với generics phức tạp), thử unbuild. Chậm hơn nhưng dùng rollup-plugin-typescript2 để đảm bảo type fidelity chặt chẽ hơn.

allowExcessArguments không liên quan ở đây — đó là một quirk của Commander.js, không phải vấn đề npm publishing.

Tài liệu tham khảo

NguồnURL
npm access tokens (chính thức)docs.npmjs.com
npm publish docs (CLI v11)docs.npmjs.com/cli/v11
npm provenance docsdocs.npmjs.com
npm Trusted Publishersdocs.npmjs.com
Node.js packages / exportsnodejs.org/api/packages
tsup docstsup.egoist.dev
changesets introgithub.com/changesets
changesets/actiongithub.com/changesets/action
Bootstrapping npm provenance w/ GitHub Actions (tháng 1, 2026)thecandidstartup.org