One Stencil Codebase, Many Consumers: Choosing Output Targets That Match Your Teams
Stencil compiles one component source into several distribution formats. The engineering decision is which output targets to enable — each one is a contract with a specific kind of consumer.
09 Aug 2026, 09:28 UTC

You built a component library in Stencil. The React team wants it, the Vue team wants it, and the marketing site — plain HTML, no bundler — wants it too. The old answer was to maintain three wrapper codebases by hand, or to pick one framework and tell everyone else to adapt. Stencil's answer is the compiler: a single source that emits several distribution formats, all declared in one config array.
The useful way to think about outputTargets is not as a build setting but as a set of contracts. Each target you enable is a promise to a specific kind of consumer, and enabling the wrong ones costs you bundle size, publish complexity, or runtime bugs. Here is how the options map to consumers, a concrete configuration, and the trade-offs worth checking before you publish.
What an output target actually is
In stencil.config.ts, outputTargets is an array where each entry describes one compilation pipeline. When you run stencil build, the compiler runs every target from the same component source. A custom element, for context, is a browser-native component registered with a tag name like <acme-button>; Stencil's different targets change how that element's code is packaged and loaded, not what it does.
| Target | What it emits | Suits | Watch out |
|---|---|---|---|
dist | ES modules, TypeScript declarations, and a lazy-loading loader entry | Bundler consumers installing from npm | Components load on demand via the loader; entry point must be documented |
dist-custom-elements | Per-component modules plus a defineCustomElements entry that registers tags | Tree-shaking bundlers; apps that want explicit registration | Registration is global — duplicate tag names throw |
www | A pre-rendered, lazy-loaded site | Demos and documentation | Not an npm artifact |
| Framework wrappers | Generated React, Vue, or Angular proxy components | Apps that want native framework ergonomics | Peer dependency on the framework; generated types can lag |
A worked configuration: core package, custom elements, and a React wrapper
Suppose your consumers are a React application, a bundler-based intranet app, and a docs site. A config shaped like this covers all three (written against Stencil v4-era option names — see the version caveat below):
import { Config } from '@stencil/core';
import { reactOutputTarget } from '@stencil/react-output-target';
export const config: Config = {
namespace: 'acme-ui',
outputTargets: [
{ type: 'dist' },
{ type: 'dist-custom-elements' },
reactOutputTarget({
componentCorePackage: 'acme-ui',
proxiesFile: '../acme-ui-react/src/generated/components.ts',
includeDefineCustomElements: true,
}),
],
};
To use it: from the library's project root, run npm install --save-dev @stencil/react-output-target, then npm run build. No elevated permissions are needed. The placeholders to replace are the namespace/componentCorePackage name and the proxiesFile path, which typically points into a sibling package (here acme-ui-react) that you publish separately with the core package and react listed as peer dependencies.
Expected checks after the build: dist/ should contain the loader entry and .d.ts declarations; dist-custom-elements/ should contain per-component modules and a defineCustomElements export; and the proxies file should contain generated components that forward props and refs to the underlying web components. One version caveat: these option names have changed across Stencil major versions — older tutorials reference deprecated keys like collectionDir or esmDir, and the React target's registration option has been named differently in past releases — so match the docs for the version you have installed rather than copying an example verbatim.
Trade-offs to weigh before you publish
- Lazy versus eager loading. The
distloader downloads component code only when a tag appears on the page, which is great for large libraries and bad for first-paint-critical components.dist-custom-elementsgives you static imports and tree-shaking — dropping unused code at build time — but your app decides when registration happens. - Global registration collisions. The browser's custom element registry forbids defining the same tag name twice. If two versions of your library end up on one page and both call
defineCustomElements, the second registration throws. Per-component imports fromdist-custom-elementsand version discipline in monorepos are the practical mitigations. - Peer dependencies. The generated React or Vue wrappers expect the consuming project to provide a compatible framework version. If it is missing or mismatched, the wrappers fail at runtime, so the wrapper package's
package.jsonshould declare the framework as a peer dependency and your README should state supported versions. - Generated types have limits. Wrapper type declarations are generated from your component props, but complex generics or conditional types in Stencil source can produce wrappers whose types lag behind the actual props. Keep public prop types simple, and re-inspect the generated proxies file after changing them.
Verify what you actually shipped
A config that builds is not the same as a package that works. Before each release, from the library root, run npm pack — it produces a .tgz of exactly what npm would publish, with no special permissions required. Inspect its contents: are the dist and dist-custom-elements folders present, and does the wrapper package reference the right entry points?
Then do the real test: install the tarballs into a throwaway Vite + React app, import one component, and confirm it renders. Check the app's build output to confirm the core package appears once rather than being duplicated per wrapper. That two-minute loop catches wrong entry points, missing files, and peer-dependency mistakes that a green build will happily hide.
Decide from the consumer list, not the feature list
The most common mistake with output targets is enabling everything because it is available. Start from an actual inventory of consumers, enable the minimum target set that serves them, and document each entry point in the README — which import a bundler user needs versus a script-tag user versus a React consumer. Then keep the pack-and-install check as your release gate. One source, several contracts, each one verified.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.