Parcel 2 Without the Hype: Zero-Config Trade-offs, package.json Targets and Common Errors
Key takeaways
Parcel 2 builds a web app from an HTML entry with no config file, transpiling TypeScript, JSX and CSS automatically. The price of zero config is that its behavior comes from package.json fields and conventions, so a stray "main" field, an env var or a stale .parcel-cache can change your build without any config file to look at.
What “zero config” really means
Parcel’s pitch is that you point it at an HTML file and it figures out the rest:
npm install --save-dev parcel
npx parcel src/index.html # dev server with HMR
npx parcel build src/index.html # production build into dist/
<!-- src/index.html -->
<!doctype html>
<html>
<body>
<div id="app"></div>
<script type="module" src="./index.ts"></script>
</body>
</html>
Parcel parses the HTML, finds the script, sees .ts, transpiles it, follows every import, picks up CSS, images and fonts, and writes hashed output files with the HTML rewritten to point at them. The JavaScript and TypeScript transformer is written in Rust (built on SWC), and work is spread across worker threads, which is why cold builds are reasonably fast without tuning.
“Zero config” does not mean “no configuration exists”. It means the configuration comes from places you might not think of as config: fields in package.json (source, targets, main, module, browserslist), dotfiles like .env, .postcssrc, .babelrc and .proxyrc, and the contents of .parcel-cache. Almost every confusing Parcel problem I have seen comes from one of those implicit inputs.
The package.json “main” trap
This is the first error many people hit, because npm init -y adds "main": "index.js":
🚨 Build failed.
@parcel/namer-default: Target "main" declares an output file path of "index.js" which does not match the compiled bundle
type "html".
package.json:5:11
> 5 | "main": "index.js",
> | ^^^^^^^^^^ Did you mean "index.html"?
That output is from Parcel 2.16. Parcel reads main, module, browser and types as library targets, because for an npm package those fields say where the built files should go. When you build an HTML app, Parcel tries to write it into the main target too, and the file type does not match.
For an application, delete main from package.json. The first time I ran into this I spent longer than I would like to admit looking for a Parcel config file, because nothing in the error hints that the problem is a field npm wrote for me years ago. It is a good illustration of the zero-config trade-off: there is no config to read, so you have to know the conventions.
source and targets
Instead of repeating the entry on every command, put it in package.json:
{
"source": "src/index.html",
"scripts": {
"dev": "parcel",
"build": "parcel build"
},
"browserslist": "> 0.5%, last 2 versions, not dead"
}
source can also be an array for multi-page sites (["src/index.html", "src/about.html"]). Each HTML file becomes its own entry, and shared JavaScript is split into common bundles automatically.
browserslist controls how far down Parcel transpiles JavaScript and which CSS prefixes it adds. If you support specific older browsers, set it explicitly rather than relying on Parcel’s defaults, which can change between releases.
Library builds
For a package, the library fields are exactly what you want:
{
"name": "my-library",
"source": "src/index.ts",
"main": "dist/main.js",
"module": "dist/module.js",
"types": "dist/types.d.ts"
}
parcel build then produces a CommonJS build for main, an ES module build for module, and a bundled .d.ts file for types. Dependencies listed in dependencies and peerDependencies are left as external imports instead of being bundled, which is what a library should do.
Custom targets are defined under targets when the defaults do not fit, for example to change the output directory or disable optimization for one target. The Parcel docs list the per-target options; I would only reach for them when the library fields are not enough.
What Parcel does not do for you
It does not type-check
Parcel strips TypeScript types and moves on. This file builds successfully:
const k: string = 42; // type error
✨ Built in 911ms
This is a deliberate speed trade-off (Vite and esbuild make the same one), but it surprises people who assume “supports TypeScript” means “catches type errors”. Add a separate check:
{
"scripts": {
"typecheck": "tsc --noEmit",
"build": "npm run typecheck && parcel build"
}
}
Environment variables are inlined, including secrets
Parcel loads .env files and replaces every process.env.NAME in your code with the literal value:
# .env
API_URL=https://api.example.com
SECRET_TOKEN=abc123
console.log(process.env.API_URL, process.env.SECRET_TOKEN);
After parcel build, both https://api.example.com and abc123 appear as plain strings in dist/*.js. Unlike Vite, which only exposes variables with a VITE_ prefix to client code, Parcel inlines whatever you reference. The safeguard is discipline: never reference a secret from browser code, and treat anything in a frontend .env as public.
parcel build sets NODE_ENV=production, and Parcel also loads environment-specific files such as .env.production and .env.local. Check the docs for the exact precedence before relying on it for anything important.
It does not give SVG React components by default
import logo from './logo.svg' gives you a URL string, not a component. To import SVGs as React components you add a transformer in .parcelrc:
{
"extends": "@parcel/config-default",
"transformers": {
"*.svg": ["...", "@parcel/transformer-svg-react"]
}
}
The "..." means “keep the default transformers and run this one after them”. Installing the plugin package is still up to you.
Things it does handle well
- CSS Modules: any
*.module.cssfile is treated as a CSS module;import styles from './Button.module.css'gives you the class-name map. - Sass, Less, PostCSS: detected by file extension or config file. In dev, Parcel can install a missing compiler package such as
sassfor you; in CI you want it indevDependenciesso builds do not depend on network installs mid-build. - Images: imports return URLs, and query parameters can resize or convert:
import thumb from './photo.jpg?width=400&as=webp'. - Code splitting: every dynamic
import()becomes a separate bundle, includingReact.lazy(() => import('./Heavy')). - Production optimization: minification (SWC’s minifier for JavaScript, Lightning CSS for CSS in current releases), tree shaking, scope hoisting and content-hashed file names.
React 18+ entry
Older tutorials still use ReactDOM.render, which was deprecated in React 18 and removed in React 19:
import { createRoot } from 'react-dom/client';
import App from './App';
createRoot(document.getElementById('root')).render(<App />);
Parcel enables React Fast Refresh automatically in development when it detects React, so component state survives edits without extra setup.
Proxying an API in development
For “CORS errors in dev”, the answer is not a port flag. Proxy the backend through the dev server with a .proxyrc.json in the project root:
{
"/api": {
"target": "http://localhost:3000/"
}
}
Now fetch('/api/users') from the page is forwarded to the backend, and the browser sees a same-origin request.
.parcel-cache: fast until it is not
Parcel stores its build graph in .parcel-cache/ (an LMDB database, data.mdb, plus graph snapshots). That cache is what makes the second build much faster than the first. It also causes a family of “this makes no sense” bugs:
- You changed a
.babelrc,.postcssrcor.envand the output did not change. - You upgraded Parcel and got errors from internal packages that no longer match.
- A Docker image or CI cache restored a
.parcel-cachebuilt by a different Parcel version or a different environment.
The fix in each case is the same:
rm -rf .parcel-cache dist
# or, for one build:
npx parcel build --no-cache
Add both directories to .gitignore, keep .parcel-cache out of Docker build contexts with .dockerignore, and if you cache it in CI, key the cache on your lockfile so a Parcel upgrade starts clean. When a build result looks impossible, clearing the cache is the first thing I try, not the last.
A related warning worth reading: if you have a .babelrc left over from an older setup, Parcel falls back to Babel for those files and tells you when the config contains only presets it already handles itself. Deleting that file usually makes builds noticeably faster, because Parcel can go back to its Rust transformer.
Useful CLI flags
parcel src/index.html --port 3000 --open # dev server on a chosen port, open browser
parcel build --public-url ./ # relative asset URLs (e.g. for subfolder hosting)
parcel build --dist-dir build # output directory
parcel build --no-source-maps # skip source maps
parcel build --no-optimize # production build without minification, for debugging
--public-url matters when the site is not served from the domain root. Without it, asset URLs start with / and 404 on a GitHub Pages project site or any subfolder deployment.
Parcel or Vite?
Both remove most bundler configuration, but they take different routes:
| Parcel 2 | Vite | |
|---|---|---|
| Entry | HTML (or package.json source) | index.html + vite.config |
| Dev server | Bundles with a persistent cache | Serves native ES modules, pre-bundles dependencies |
| Config | package.json fields, .parcelrc | vite.config.ts |
| Env vars in client | Any referenced process.env.X | Only VITE_-prefixed, via import.meta.env |
| Ecosystem | Smaller | Official templates for most frameworks, large plugin ecosystem |
For a new React, Vue or Svelte app I would choose Vite today, mostly for the ecosystem: framework templates, test tooling (Vitest) and documentation all assume it. Parcel still earns its place for multi-page static sites, prototypes where I do not want a config file at all, and small libraries, where the main/module/types fields give you a dual-format build with type declarations with almost no setup. If you need fine-grained control over every loader and chunk, webpack or Rollup are still the more configurable options.