Moving a library from tsup to tsdown is one command, npx tsdown-migrate, followed by a check of four defaults that change underneath you: output format goes from CommonJS to ESM, the output directory is now cleaned before every build, declaration files are generated whenever package.json has a types field, and the compile target is read from engines.node. Nine options are renamed and six are gone.
The reason to do it is on tsup’s own README, which as of October 2026 says the project “is not actively maintained anymore” and points to tsdown. tsup 8.5.1 still installs and still works. It just stops moving. If you already went through the Vite 8 Rolldown migration for your apps, tsdown is the same bundler reaching your packages.
This post was written against tsdown 0.23.1, published on 10 October 2026, which pins rolldown ~1.2.13 and generates declarations through rolldown-plugin-dts.
What does tsdown-migrate actually do?
The migration guide ships a CLI that rewrites your config and installs the new dependency:
# single package
npx tsdown-migrate
# a workspace: every package with a tsup config
npx tsdown-migrate packages/*
# see the diff without writing it
npx tsdown-migrate --dry-run
--yes skips the prompts, --package-manager <name> tells it which lockfile you keep, and --no-install leaves the dependency install to you.
There is a catch in the version it installs. The tool pins tsdown 0.22.14 on purpose, because that is the last release that still accepts the tsup-shaped option names and warns about them. The docs put it plainly: newer versions “have removed these options entirely”, so an old external key “fails type checking” and is “silently ignored at runtime”. Run the build on 0.22.14, clear every deprecation warning it prints, and only then bump to 0.23.x. If you jump straight to latest, a stale option does not error. It disappears, and your bundle quietly starts including a dependency you meant to externalise.
Which defaults change from tsup to tsdown?
Four, and the first one is the one that breaks consumers.
format defaults to esm in tsdown. tsup defaulted to cjs. A config that never set format was shipping dist/index.js as CommonJS, and after migration the same file name holds an ES module. Anything that does require('your-package') without an exports map stops working. Set format: ['esm', 'cjs'] explicitly if you still need both, and treat the migration as the moment to decide whether you do.
clean defaults to true. tsup left dist alone unless you asked. If a second tool writes into the same directory (a CSS build, a copied asset folder), its output is now deleted at the start of every tsdown run. Either move it or set clean: false.
dts is auto-enabled when package.json has types, typings, or a types entry inside exports. In tsup it was off until you turned it on. For most published packages this is the right default, but a package that never shipped types will now start trying to, and fail if the source does not type-check.
target is read from engines.node. The migration guide lists tsup’s default as none. If your engines says >=18 and your code uses something newer, tsdown now downlevels it. If engines is missing, nothing changes.
Which tsup options are renamed or removed?
The renames are mechanical and the migrate tool handles all of them. entryPoints becomes entry. esbuildPlugins becomes plugins, and the plugins themselves have to be Rolldown or Vite-compatible plugins rather than esbuild ones. outExtension becomes outExtensions, cjsInterop becomes cjsDefault, publicDir becomes copy. Three change shape rather than name: bundle: false is unbundle: true, removeNodeProtocol: true is nodeProtocol: 'strip', and injectStyle: true is css: { inject: true }.
The two you are most likely to have set by hand are external and noExternal. Both moved into a deps namespace, deps.neverBundle and deps.alwaysBundle, and the dependencies page is explicit that mixing the old and new names in one config throws an error. skipNodeModulesBundle: true is deps: { neverBundle: true }.
Here is a typical tsup config and its tsdown equivalent, written out rather than generated, so the shape changes are visible:
// tsup.config.ts (before)
import { defineConfig } from 'tsup';
export default defineConfig({
entryPoints: ['src/index.ts', 'src/cli.ts'],
format: ['esm', 'cjs'],
dts: true,
clean: true,
external: ['react'],
noExternal: ['tiny-invariant'],
outExtension: ({ format }) => ({ js: format === 'cjs' ? '.cjs' : '.mjs' }),
});
// tsdown.config.ts (after)
import { defineConfig } from 'tsdown';
export default defineConfig({
entry: ['src/index.ts', 'src/cli.ts'],
format: ['esm', 'cjs'],
deps: {
neverBundle: ['react'],
alwaysBundle: ['tiny-invariant'],
},
exports: true,
});
dts and clean are gone from the second file because they are now the defaults. I dropped outExtensions too, to let tsdown choose the file names; check what it writes to dist before you trust an exports map written for the old names.
Six options have no equivalent at all: splitting: false, metafile, swc, experimentalDts, legacyOutput, and tsup’s experimental plugins. The guide’s advice is to open an issue if one of them matters to you. metafile is the one I would miss. If you were feeding it to a bundle analyser, you need another way to measure output size before you can show the migration did not regress it.
How do dependencies get bundled now?
tsdown’s default is closer to what people assume tsup did. Anything in dependencies, peerDependencies or optionalDependencies is treated as external and stays out of the bundle. Anything in devDependencies is bundled, but only if your source actually imports it. Phantom dependencies, packages that are in node_modules but not in your package.json, follow the same rule: bundled if used. That is the route by which a package you never declared ends up inside your published artefact.
Two options make that strict. deps.onlyBundle is a whitelist: only the listed packages may be bundled and anything else throws. deps.onlyImport is enforced after the build and restricts which packages the output is allowed to import at runtime. For a library that has been bitten by an accidental inline copy of a dependency, onlyBundle: [] is a one-line guarantee that the bundle contains your code and nothing else.
Should you let tsdown write the exports field?
Setting exports: true makes tsdown generate the exports map in package.json from the entries it just built, and exports.legacy: true adds top-level main, module and types for older resolvers. The package exports page adds exports.all to include every output file rather than only the entry points, exports.devExports to point a named condition at your source during development, and exports.customExports to override anything it got wrong.
The devExports option writes to publishConfig.exports, which the docs flag as “not supported by npm”. pnpm and yarn honour it; npm publishes whatever is in exports, so with npm your dev-only source condition would ship to the registry.
Generated or not, run a checker over the result. tsdown lists publint and @arethetypeswrong/core as optional peer dependencies, and the exports page tells you to either review the generated field or enable publint. A wrong types condition order is the kind of mistake that only shows up in a consumer’s editor, and trusted publishing from CI does not make a bad exports map any less published.
When are declaration files fast, and when are they not?
The dts page describes two paths. If your tsconfig.json has isolatedDeclarations: true, declarations are produced by oxc-transform, which does not need to type-check the program and is the fast route. Without it, tsdown falls back to the TypeScript compiler, which is the same cost you were paying in tsup.
isolatedDeclarations requires every exported function and variable to have an explicit type annotation, so turning it on is a code change, not a config change. For a small package it is an afternoon. For one with a lot of inferred return types it is a project, and the tsdown migration is not the time to start it. Migrate on the tsc path first, measure, and do the annotations as their own change. dts.sourcemap: true gives you declaration maps on either path.
What I would do
For a package on tsup 8 with a hand-written config, I would migrate now, in two commits. The first runs tsdown-migrate, stays on 0.22.14, fixes every deprecation warning, pins format explicitly, sets deps.onlyBundle so the inlined dependencies cannot change, and diffs dist against the previous release. The second bumps to 0.23.x, turns on exports: true with publint checking the result, and publishes.
I would wait if the config leans on metafile for size budgets or on an esbuild plugin with no Rolldown equivalent. tsup 8.5.1 is unmaintained, not broken, and a working build with a measured bundle is worth more than a faster one you cannot compare.
Whoooop builds and maintains TypeScript libraries and the monorepos they live in, and we have moved build pipelines between esbuild, Rollup and Rolldown without breaking downstream consumers. If a migration like this is sitting on your backlog, our TypeScript development work covers it.