@sanity/tsdown-config
v0.28.1
Published
Shared configuration for tsdown
Readme
Shared config for tsdown
pnpm add --save-dev @sanity/tsdown-config tsdownCreate a tsdown.config.ts file with:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({tsconfig: 'tsconfig.dist.json'})React Compiler
The same React Compiler feature as @sanity/pkg-utils is available. It runs the
React Compiler on the source files before they are
bundled, so published components are memoized automatically. The compiler needs to be
installed separately:
pnpm add --save-dev oxc-transform-reactThen enable it:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
reactCompiler: true,
})Pass an object to configure the compiler, using the same options as babel-plugin-react-compiler:
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
reactCompiler: {target: '18'},
})Transforms (transform)
transform picks which implementation of the compiler runs:
| transform | Runs | Install |
| ----------------- | ------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- |
| 'oxc' (default) | oxc-transform-react, the Rust port | pnpm add -D oxc-transform-react |
| 'babel' | babel-plugin-react-compiler | pnpm add -D @rolldown/plugin-babel @babel/core babel-plugin-react-compiler |
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
reactCompiler: {target: '19', transform: 'babel'},
})Good to know about the default 'oxc' (via @vitejs/plugin-react's compiler option):
- One native pass compiles, strips TypeScript, and lowers JSX. Custom
jsxImportSource? Opt into'babel'. - Options are the serializable subset: no
logger, no function-valuedsources.
'babel' runs the reference implementation and covers those cases. It's opt-in: babel never
lands in node_modules unless you set transform: 'babel' and install the toolchain. Only
the implementation you use needs to be installed — the other one's options simply stay
untyped (no declare module stubs needed).
React Server Components (reactServer)
React Server Components refuse to load React Compiler output — react/compiler-runtime throws
in the react-server environment, since memoization can never pay off for components that
render exactly once. The React team's guidance for libraries that ship compiled code is to
publish two entrypoints: the compiled one for
the client, and an uncompiled one under the react-server export condition.
The reactServer option of reactCompiler (an option of this config, never forwarded to the
compiler) bakes that pattern in. It's experimental (@alpha) and not covered by semver: it can
change behavior or be removed entirely in a minor version.
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
reactCompiler: {target: '19', reactServer: true},
})Every entry is built twice from the same source, and the only difference is that React Compiler
auto-memoization is applied to the non-react-server output. The uncompiled build lands next to
the compiled one with .react-server inserted before the extension (dist/index.js ↔
dist/index.react-server.js), only the compiled build emits the .d.ts files, and — when the
exports feature is enabled — every entry export gains a react-server condition,
with a types condition specified before it (the .react-server. files have no declaration
siblings, so the explicit types condition points every resolution mode — including consumers
with react-server in their customConditions — at the compiled build's declarations):
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"react-server": "./dist/index.react-server.js",
"default": "./dist/index.js"
}
}
}React Server Components resolve the uncompiled build, everything else (client components, SSR,
plain Node) resolves the compiled one. The .react-server. files never become export subpaths of
their own.
Nothing is stripped from either output, so pair reactServer with deleting manual
useMemo/useCallback calls from the source: server components stop paying for memoization
that cannot pay off, and client components get the compiler's finer-grained auto-memoization
instead of the hand-written hooks.
reactServer is meant for isomorphic libraries that render in both worlds without a
'use client' directive (pure renderers like @portabletext/react). Client-only packages that
ship 'use client' don't need it — they always load through the default condition anyway.
Also note the general dual-entrypoint caveat: the two conditions are two module instances, so
module-scope identity (e.g. createContext) is not shared between server and client trees —
which is exactly the boundary React draws for such libraries anyway.
With reactServer the config resolves to two tsdown configs, so the
isolated declarations annotation becomes
satisfies Promise<UserConfig[]>.
styled-components
If your package uses styled-components, enable the same styledComponents transform that @sanity/pkg-utils has:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
styledComponents: true,
})It adds displayName (better debugging) and componentId (avoids SSR hydration mismatches) to your styled components, and minifies the CSS in tagged template literals.
Unlike @sanity/pkg-utils it doesn't require installing babel-plugin-styled-components, as it uses oxc's native port of the babel plugin.
Pass an object to customize the transform, using the same options as babel-plugin-styled-components:
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
styledComponents: {namespace: 'my-package'},
})vanilla-extract
The same vanillaExtract feature as @sanity/pkg-utils is available. It extracts the CSS from
.css.ts files into a separate file (dist/bundle.css by default), minified and lowered with
lightningcss for the
@sanity/browserslist-config
browsers by default. Under the hood it uses
@sanity/vanilla-extract-tsdown-plugin,
a tsdown-native port of @vanilla-extract/rollup-plugin, so enabling it doesn't pull rollup
into your project. Start by installing @vanilla-extract/css for authoring the .css.ts files:
pnpm add --save-dev @vanilla-extract/cssimport {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
vanillaExtract: true,
})By default (inject: true with exports: {nodeCompat: true}) the conditional CSS export pattern
is wired up automatically:
- injects the self-referential
import "<pkg>/bundle.css"into the entry chunks that use vanilla-extract styles, - emits a no-op
bundle-css.jsshim (plusbundle-css.d.ts) for runtimes that cannot import.cssfiles, and - writes the conditional
"./bundle.css"export topackage.json(types→ the shim's.d.ts,browser/style→ the real CSS,node/default→ the shim).
The result is that import "<pkg>/bundle.css" resolves to the real CSS in bundlers/browsers and to
the no-op shim in Node and similar runtimes. Make sure the extracted CSS survives tree-shaking in
consumers by adding it to sideEffects in package.json:
{
"sideEffects": ["*.css"]
}Pass an options object instead of true to customize - the options are modeled after the
css options of @tsdown/css (e.g. fileName, minify,
target, lightningcss). inject and exports are independent: set exports: true for a
plain (browser-only) "./bundle.css" export without the shim, exports: false for a relative
import "./bundle.css" instead of the export pattern, or inject: false to publish the CSS
without importing it automatically (for packages whose consumers import the subpath themselves).
[!NOTE]
inject: {nodeCompat: true}is deprecated. It means{inject: true, exports: {nodeCompat: true}}and still works, with a warning:nodeCompatconfigures how the CSS file is published, not how the import is injected, so it moved toexports.
Two Sanity-flavored defaults diverge from the bare plugins (which match @tsdown/css exactly):
minifydefaults totrue- published Sanity libraries ship minified CSS. Setminify: falsefor readable output.- The CSS syntax lowering
targetdefaults to tsdown's top-leveltarget, and when the effective target is undefined or names no browsers (e.g.'node20', also what tsdown derives fromengines.node- it speaks to the JS runtime, not the browsers the extracted CSS runs in), the lowering targets are resolved from@sanity/browserslist-configand passed throughlightningcss.targets. The bare plugins (like@tsdown/css) would skip lowering in that case.target: falsedisables lowering entirely, and a user-providedlightningcss.targetswins over the fallback.
vanillaExtract can be combined with the css option below when a package also uses
CSS modules (or other @tsdown/css features) — the two pipelines write to different files by
default (bundle.css vs style.css) and do not interfere with each other.
css
tsdown's experimental css option enables the @tsdown/css
pipeline for plain CSS, CSS modules, preprocessors, and Lightning CSS / PostCSS. Install
@tsdown/css in the project first — it is an
optional peer dependency:
pnpm add --save-dev @tsdown/cssimport {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
css: {
modules: {localsConvention: 'camelCase'},
},
})The options are @tsdown/css's, plus an exports option this config implements on top, with the
same two Sanity-flavored defaults as vanillaExtract:
exportsdefaults to{nodeCompat: true}, wiring up the same conditional CSS export pattern: the self-referentialimport "<pkg>/style.css", a no-opstyle-css.jsshim with itsstyle-css.d.tsdeclaration, and the conditional"./style.css"export inpackage.json.@tsdown/csshas no equivalent — its owninjectemits a relativeimport "./style.css", which throws in runtimes that cannot load.cssfiles. Setexports: truefor a plain (browser-only) CSS export, orexports: falseto fall back to the relative injection.minifydefaults totrue, and browserless CSS syntax lowering targets fall back to@sanity/browserslist-config— exactly as described forvanillaExtractabove.
Both css and vanillaExtract can be enabled in the same config. Use that when a package
authors styles with vanilla-extract and CSS modules (.module.css):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
vanillaExtract: true,
css: {
modules: {localsConvention: 'camelCase'},
},
})vanilla-extract extracts into dist/bundle.css, while @tsdown/css merges other CSS — including
scoped CSS modules — into dist/style.css. Each pipeline publishes its own conditional export and
emits its own shim, so they never collide. See the
InlineConfig.css API reference
for the full option surface.
dts
tsdown's dts option is passed through as-is. By default tsdown
auto-detects it from package.json (it's enabled when a types field or a types condition in
exports is present). Pass an object to customize how the .d.ts files are generated, for example
to use tsgo (the same feature as the tsgo option
in @sanity/pkg-utils, requires either typescript v7 or @typescript/native-preview to be
installed — with typescript v7 it's enabled automatically):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
dts: {generator: 'tsgo'},
})tsdoc
Runs API Extractor after the build (via tsdown's build:done hook)
to check that TSDoc tags are valid and release tags are correct. Off by default — set
tsdoc: true to enable, or pass an options object to customize rules and custom tags:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
tsdoc: true,
})export default defineConfig({
tsconfig: 'tsconfig.dist.json',
tsdoc: {
rules: {
// do not require internal members to be prefixed with `_`
'ae-internal-missing-underscore': 'off',
},
},
})The check runs against every entry .d.ts / .d.mts / .d.cts file the build emitted. API
Extractor is loaded only when that hook runs (or when you import the programmatic API), so
enabling tsdoc does not pull those dependencies into the root @sanity/tsdown-config entry.
It is also available as checkTsdoc from @sanity/tsdown-config/tsdoc for hosts that want to
run it outside the build:
import {checkTsdoc} from '@sanity/tsdown-config/tsdoc'outDir
tsdown's outDir option is passed through as-is.
When left undefined, tsdown writes to dist (the same default as @sanity/pkg-utils):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
outDir: 'lib',
})clean
tsdown's clean option is passed through as-is. When left
undefined, tsdown defaults to true and removes outDir (dist by default) before each build.
Prefer an array of folders over a separate "clean" script in package.json. That way
tsdown / pnpm build clears the directories itself — packages don't need rimraf, a clean
script, or prebuild / run-s clean build wiring:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
// Instead of `"clean": "rimraf dist coverage"` (and running it before build):
clean: ['dist', 'coverage'],
})A string[] replaces tsdown's default (true → clean outDir), so include outDir (usually
'dist') in the array when you still want it cleaned alongside other folders. Pass false to
skip cleaning entirely.
Isolated declarations
If you're using the @sanity/tsconfig/isolated-declarations
preset — which makes tsdown generate the .d.ts files with oxc's fast isolated declarations
transform — annotate the default export of tsdown.config.ts with satisfies Promise<UserConfig>:
import {defineConfig} from '@sanity/tsdown-config'
import type {UserConfig} from 'tsdown'
export default defineConfig() satisfies Promise<UserConfig>Without the annotation, type-checking tsdown.config.ts with the @sanity/tsconfig presets (they
enable declaration) fails with TS2883 in pnpm projects: the inferred type of the default export
can only be named through @sanity/tsdown-config's own copy of tsdown, which isn't portable.
satisfies Promise<UserConfig> names the type through your own tsdown dependency instead.
With reactCompiler.reactServer the config resolves to
two tsdown configs, so the annotation becomes satisfies Promise<UserConfig[]>.
Keep the isolated-declarations preset scoped to the tsconfig that tsdown builds with (e.g. a
tsconfig.dist.json that only includes ./src). If isolatedDeclarations covers
tsdown.config.ts itself, the default export can't be inferred at all (TS9037), and the config has
to move into an explicitly annotated variable instead:
import {defineConfig} from '@sanity/tsdown-config'
import type {UserConfig} from 'tsdown'
const config: Promise<UserConfig> = defineConfig()
export default configdefine
tsdown's define option is also passed through as-is. It replaces global identifiers with constant
expressions at build time (the same feature as the define option in @sanity/pkg-utils):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
define: {'process.env.NODE_ENV': JSON.stringify('production')},
})sourcemap
tsdown's sourcemap option is forwarded with a
true default (the same as @sanity/pkg-utils). tsdown itself defaults to false and does not
read sourceMap from the tsconfig:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
sourcemap: false,
})minify
@sanity/tsdown-config sets tsdown's minify option to compression
only, with function and class names preserved:
{
compress: {keepNames: {function: true, class: true}},
mangle: false,
codegen: false,
}Consumers' production builds minify node_modules again anyway, so mangling identifiers or
stripping whitespace in the published dist would not shrink final app bundles - it would only
hurt debuggability. The compress pass still applies constant folding and dead code elimination
(e.g. evaluating defined branches away), and keepNames stops it from stripping
otherwise-unreferenced function/class names such as the inner name in
forwardRef(function Button(…) {…}). React DevTools reads that name via Function.name - the
tree-shakeable alternative to a top-level Button.displayName = '…' assignment, which is a side
effect that pins unused components into consumer bundles
(sanity-io/ui#2435). Names stripped at publish time
are unrecoverable in userland, while keeping them costs a fraction of a kilobyte.
Override it by merging over the returned config, like everything else:
import {defineConfig} from '@sanity/tsdown-config'
import {mergeConfig} from 'tsdown'
export default mergeConfig(await defineConfig({tsconfig: 'tsconfig.dist.json'}), {
// e.g. drop the names again for the smallest possible dist:
minify: {compress: true},
})deps
tsdown's deps option is forwarded. When platform is
'neutral' (the default), neverBundle always includes /^node:/ so node built-ins stay
external, and userland neverBundle entries are appended rather than replacing that default
(tsdown's mergeConfig would replace the array):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
// Resulting neverBundle: [/^node:/, /^my-package(\/|$)/]
deps: {neverBundle: [/^my-package(\/|$)/]},
})'neutral' also restores inputOptions.resolve.mainFields: ['module', 'main'] for inlined
dependencies that ship no exports map. Prefer it over 'node' for packages that also run in
the browser - 'node' makes CommonJS-interop emit a module-scope
createRequire(import.meta.url) for inlined CJS deps, which crashes browser-bundled consumers.
target
tsdown's target option is also passed through as-is. It
downlevels JS syntax for the given runtimes (esbuild-style target strings), and doubles as the
default CSS syntax lowering target when vanillaExtract is enabled (browserless targets like
node20 don't affect the CSS - it falls back to @sanity/browserslist-config):
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
target: ['chrome90', 'safari16'],
})exports
tsdown's exports option is forwarded with
different defaults, suited for publishing Sanity libraries:
enabled: true- theexportsmap inpackage.jsonis generated on every build, whetherCIis set or not. Gating on'local-only'/'ci-only'surprised environments like Cursor Cloud that setCI=truewithout intending to skippackage.jsonrewrites, anddevExports: truewhen pnpm is detected - the localexportsmap points at the source files (so monorepo siblings and editors resolve them directly), whilepublishConfig.exportsreceives the built files. This default is omitted for other or unknown package managers because they do not all reliably applypublishConfig.exportswhen publishing.
Userland values apply with tsdown's mergeConfig semantics: an object deep-merges over the
defaults (so individual fields can be overridden), while any other value - false to disable
exports generation, or a bare CI condition ('ci-only'/'local-only') - replaces them entirely:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
exports: {all: true},
})The package-manager detection behind the devExports default only runs when that default can
still apply - it is skipped when the userland value replaces the defaults (false, true, a
bare CI condition) or sets devExports explicitly, so those configs behave identically across
package managers without any filesystem probing.
cwd
tsdown's cwd option is forwarded as-is, and also used for the package-manager detection behind
the exports devExports default (instead of process.cwd()). Config files can
leave it unset; set it when driving builds programmatically for a package in another directory,
e.g. from a monorepo script or a tool composing this config:
import {defineConfig} from '@sanity/tsdown-config'
const config = await defineConfig({
cwd: '/path/to/package',
tsconfig: 'tsconfig.dist.json',
})bundleAnalyzer
Enables Rolldown's experimental
bundleAnalyzerPlugin to emit a report of
what the package itself bundles — chunks, modules, dependency chains — next to the build output.
The plugin is currently experimental (exported from rolldown/experimental); this option is
@alpha to match.
Analysis adds work to the build, so the option stays off by default. Typical usage is an
env-gated opt-in so everyday pnpm build / publish is unchanged:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
bundleAnalyzer: process.env.ENABLE_BUNDLE_ANALYZER === 'true',
})true selects format: 'md' — an LLM-friendly markdown report (analyze-data.md in outDir)
— rather than the plugin's own 'json' default. Pass an object to customize:
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
bundleAnalyzer: {
format: 'json',
fileName: 'bundle-analysis.json',
},
})The report is not a publishable artifact. Exclude it from package.json files so an
accidental analyze build cannot ship it:
{
"files": ["dist", "!dist/analyze-data.md"]
}With reactCompiler.reactServer only the compiled
(default) variant is analyzed — the react-server variant skips it so the two parallel
builds don't overwrite one report in the shared outDir.
checks
Rolldown's checks.circularDependency
warning is enabled by default (Rolldown itself defaults it to false). Circular imports inflate
bundle size and can cause execution-order issues, so library builds surface them as warnings.
Cycles that only exist between declaration files are filtered out - see
suppressWarnings.
To turn the warning off, merge over the returned config:
import {defineConfig} from '@sanity/tsdown-config'
import {mergeConfig} from 'tsdown'
export default mergeConfig(await defineConfig({tsconfig: 'tsconfig.dist.json'}), {
checks: {circularDependency: false},
})suppressWarnings
The checks.circularDependency check above also applies to the declaration bundling pass, where
it reports cycles between the emitted .d.ts modules. Those imports are type-only and erased at
runtime, so the cycles carry none of the hazards the check exists to surface - and they're
unavoidable for mutually referencing public types, e.g. a DocumentNode whose fields are
FieldNodes that point back at their parent document. In
sanity-io/sanity#13753 109 of 136 cycle
warnings were declaration-only, drowning out the 27 real ones.
So defineConfig sets tsdown's suppressWarnings to drop CIRCULAR_DEPENDENCY warnings whose
entire cycle consists of declaration files (.d.ts/.d.mts/.d.cts). A cycle that includes
even one runtime module still warns.
Adding your own suppressions - strings (matched with includes), regular expressions (matched
with test), or a predicate - goes through the suppressWarnings option, which is OR'd with the
built-in one rather than replacing it:
import {defineConfig} from '@sanity/tsdown-config'
export default defineConfig({
tsconfig: 'tsconfig.dist.json',
suppressWarnings: [/EMPTY_BUNDLE/, 'node_modules/some-dep'],
})Suppression happens before failOnWarn turns warnings into errors, so a suppressed warning never
fails the build. To drop the built-in suppression instead of adding to it, merge over the returned
config - mergeConfig replaces functions:
import {defineConfig} from '@sanity/tsdown-config'
import {mergeConfig} from 'tsdown'
export default mergeConfig(await defineConfig({tsconfig: 'tsconfig.dist.json'}), {
// Restores every warning, including the declaration-only cycles
suppressWarnings: () => false,
})Everything else: mergeConfig
defineConfig deliberately only exposes options you're likely to change. For anything else, merge
tsdown options over the returned config with tsdown's own mergeConfig - defineConfig returns a
promise, so await it first:
import {defineConfig} from '@sanity/tsdown-config'
import {mergeConfig} from 'tsdown'
export default mergeConfig(await defineConfig({tsconfig: 'tsconfig.dist.json'}), {
// Any tsdown option, e.g. opting out of hashed chunk filenames:
hash: false,
})Programmatic composition
defineConfig() output is a mergeConfig-safe base, and that is a supported contract - tools
like @sanity/pkg-utils compose their own opinions over it programmatically. mergeConfig
applies with tsdown's semantics:
plugins(top-level,inputOptions,outputOptions) are appended, so layering your own plugins never clobbers the ones this config sets up (React Compiler, vanilla-extract, bundle analyzer),- plain objects deep-merge over the defaults (e.g.
minify: {mangle: true}keeps thecompress/codegendefaults), and - scalars and non-plugin arrays replace (e.g.
publint: false,format: ['esm']).
Combined with cwd, a host can resolve a package-specific config without touching
process.cwd():
import {defineConfig} from '@sanity/tsdown-config'
import {build, mergeConfig} from 'tsdown'
const config = mergeConfig(await defineConfig({cwd, tsconfig: 'tsconfig.dist.json'}), {
// the host's own opinions, e.g. computed targets or extra plugins
})
await build({...config, config: false})