· 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 Ethan
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ị
- Node ≥ 22.14.0 và npm CLI ≥ 11.5.1 (kiểm tra bằng
node -vvànpm -v) - Tài khoản GitHub (để lấy provenance qua OIDC hoặc Granular Access Token)
- Tài khoản npm (
npm logintại npmjs.com) - Các ví dụ dùng pnpm (pnpm vs npm — điều gì thực sự thay đổi khi bạn chuyển đổi); npm và Yarn hoạt động tương tự ở bước publish
Bước 1 — Cấu hình package.json với exports map chuẩn
Field exports thay thế main và module 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ả.mjsvà.cjsdts: true— tạo file declaration*.d.tskèm theo outputclean: true— xóadist/trước mỗi lần build để file cũ không còn sót lạioutExtension— buộc extension rõ ràng.mjs/.cjsthay vì.js. Bắt buộc khi"type": "module"được đặt trongpackage.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 publish và npm 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ồn | URL |
|---|---|
| npm access tokens (chính thức) | docs.npmjs.com |
| npm publish docs (CLI v11) | docs.npmjs.com/cli/v11 |
| npm provenance docs | docs.npmjs.com |
| npm Trusted Publishers | docs.npmjs.com |
| Node.js packages / exports | nodejs.org/api/packages |
| tsup docs | tsup.egoist.dev |
| changesets intro | github.com/changesets |
| changesets/action | github.com/changesets/action |
| Bootstrapping npm provenance w/ GitHub Actions (tháng 1, 2026) | thecandidstartup.org |