6 min read
Config & Plugin Architecture
Plugin order is execution order, aliases are a contract with tsconfig, and one config file beats three that drift.
Principles
Plugin order is execution order, not just a list
Vite plugins run in the order declared (respecting `enforce: 'pre'|'post'`), and two plugins that transform the same file produce different output depending on which runs first. Treat the plugins array as a pipeline, not a bag.
`resolve.alias` is a build-time contract, not documentation
An alias that isn't mirrored in `tsconfig.json`'s `paths` compiles fine and breaks type-checking, or the reverse. The two must agree, or editors and the build silently disagree about what a path means.
One config file, environment-branched inside it
`defineConfig` receiving a function `(env) => ({...})` beats maintaining `vite.config.dev.ts` / `vite.config.prod.ts` siblings that drift from each other the first time someone updates only one.
A plugin's job is one transform, not a grab bag
A plugin that both transforms SVGs and injects env vars is two plugins wearing one name. Splitting them makes each independently testable and independently disable-able.
Know which plugins run in dev vs build
Some plugins only run for production builds via `apply: 'build'`. A bug that 'only happens in prod' is often a plugin that never ran in dev at all.
Patterns
What goes wrong, what to do instead, and why the difference matters.
Plugin order determining output
Avoid
export default defineConfig({
plugins: [
legacy({ targets: ["defaults"] }), // transpiles down...
react(), // ...before JSX is even compiled
],
});legacy() runs first and tries to transpile syntax that hasn't been compiled from JSX yet — order matters, and this one is backwards.
Prefer
export default defineConfig({
plugins: [
react(), // compile JSX/TS first
legacy({ targets: ["defaults"] }), // then transpile the output
],
});Each plugin receives output it can actually operate on, because the pipeline runs in the order the array declares.
Alias drifting from tsconfig
Avoid
// vite.config.ts
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
// tsconfig.json has no matching "paths" entry — the build resolves
// "@/lib/utils" but the editor reports "Cannot find module".The build and the type-checker/editor disagree about what `@` means. Red squiggles everywhere, build still passes.
Prefer
// vite.config.ts
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
// tsconfig.json
// { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }Both tools agree on the same alias, from the same source of truth stated deliberately in two places.
Config duplicated per environment vs branched in one file
Avoid
// vite.config.dev.ts, vite.config.staging.ts, vite.config.prod.ts
// Three files, one of which didn't get the last plugin update.Three near-identical files that only stay in sync if someone remembers to edit all three.
Prefer
export default defineConfig(({ mode }) => ({
plugins: [react(), mode === "production" && legacy()].filter(Boolean),
define: {
__API_URL__: JSON.stringify(mode === "production" ? PROD_API : DEV_API),
},
}));One file, one source of truth, branching on `mode` instead of duplicating structure.
Review checklist
What I look for when reviewing a pull request that touches this area.
- Plugin array order reflects the actual transform pipeline, with `enforce` used where order alone can't express it.
- `resolve.alias` and `tsconfig.json`'s `paths` are kept in agreement, not maintained independently.
- One `vite.config.ts` branches on `mode`/`command` instead of multiple near-duplicate config files.
- Each custom plugin does one transform and is named for what it does.
- `apply: 'build'` / `apply: 'serve'` is used deliberately when a plugin shouldn't run in both contexts.
- Config changes are verified in both `vite dev` and `vite build` before merging, not just one.
Take the skill
Everything above, generated into a skill file your agent can load. Copy it, or download it and commit it to your repo.
.claude/skills/vite-config-and-plugins/SKILL.mdDrop into .claude/skills/ or .cursor/skills/ — the frontmatter drives when the skill loads.
---
name: vite-config-and-plugins
description: >-
Vite config and plugin architecture: plugin ordering and enforce hooks,
alias contracts with tsconfig, single-file environment branching, and
scoping plugins to dev or build. Use when adding plugins or changing
vite.config.ts.
argument-hint: "[plugin or config change]"
license: MIT
metadata:
author: John Felix Lim
version: "1.0.0"
---
# Config & Plugin Architecture
Apply when adding a plugin or changing vite.config.ts. Before adding a plugin, check whether an existing one already covers the transform, and place the new one in the array where its input is guaranteed to be in the right shape.
## Rules
1. **Plugin order is execution order, not just a list** — Vite plugins run in the order declared (respecting `enforce: 'pre'|'post'`), and two plugins that transform the same file produce different output depending on which runs first. Treat the plugins array as a pipeline, not a bag.
2. **`resolve.alias` is a build-time contract, not documentation** — An alias that isn't mirrored in `tsconfig.json`'s `paths` compiles fine and breaks type-checking, or the reverse. The two must agree, or editors and the build silently disagree about what a path means.
3. **One config file, environment-branched inside it** — `defineConfig` receiving a function `(env) => ({...})` beats maintaining `vite.config.dev.ts` / `vite.config.prod.ts` siblings that drift from each other the first time someone updates only one.
4. **A plugin's job is one transform, not a grab bag** — A plugin that both transforms SVGs and injects env vars is two plugins wearing one name. Splitting them makes each independently testable and independently disable-able.
5. **Know which plugins run in dev vs build** — Some plugins only run for production builds via `apply: 'build'`. A bug that 'only happens in prod' is often a plugin that never ran in dev at all.
## Patterns
### Plugin order determining output
**Avoid** — legacy() runs first and tries to transpile syntax that hasn't been compiled from JSX yet — order matters, and this one is backwards.
```ts
export default defineConfig({
plugins: [
legacy({ targets: ["defaults"] }), // transpiles down...
react(), // ...before JSX is even compiled
],
});
```
**Prefer** — Each plugin receives output it can actually operate on, because the pipeline runs in the order the array declares.
```ts
export default defineConfig({
plugins: [
react(), // compile JSX/TS first
legacy({ targets: ["defaults"] }), // then transpile the output
],
});
```
### Alias drifting from tsconfig
**Avoid** — The build and the type-checker/editor disagree about what `@` means. Red squiggles everywhere, build still passes.
```ts
// vite.config.ts
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
// tsconfig.json has no matching "paths" entry — the build resolves
// "@/lib/utils" but the editor reports "Cannot find module".
```
**Prefer** — Both tools agree on the same alias, from the same source of truth stated deliberately in two places.
```ts
// vite.config.ts
export default defineConfig({
resolve: { alias: { "@": path.resolve(__dirname, "./src") } },
});
// tsconfig.json
// { "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }
```
### Config duplicated per environment vs branched in one file
**Avoid** — Three near-identical files that only stay in sync if someone remembers to edit all three.
```ts
// vite.config.dev.ts, vite.config.staging.ts, vite.config.prod.ts
// Three files, one of which didn't get the last plugin update.
```
**Prefer** — One file, one source of truth, branching on `mode` instead of duplicating structure.
```ts
export default defineConfig(({ mode }) => ({
plugins: [react(), mode === "production" && legacy()].filter(Boolean),
define: {
__API_URL__: JSON.stringify(mode === "production" ? PROD_API : DEV_API),
},
}));
```
## Review checklist
- [ ] Plugin array order reflects the actual transform pipeline, with `enforce` used where order alone can't express it.
- [ ] `resolve.alias` and `tsconfig.json`'s `paths` are kept in agreement, not maintained independently.
- [ ] One `vite.config.ts` branches on `mode`/`command` instead of multiple near-duplicate config files.
- [ ] Each custom plugin does one transform and is named for what it does.
- [ ] `apply: 'build'` / `apply: 'serve'` is used deliberately when a plugin shouldn't run in both contexts.
- [ ] Config changes are verified in both `vite dev` and `vite build` before merging, not just one.
---
From the John Felix Lim Frontend Engineering Playbook — https://github.com/JohnFelixLim