All topics
Vite

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

ts
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

ts
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

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".

The build and the type-checker/editor disagree about what `@` means. Red squiggles everywhere, build still passes.

Prefer

ts
// 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

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.

Three near-identical files that only stay in sync if someone remembers to edit all three.

Prefer

ts
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.

Download
.claude/skills/vite-config-and-plugins/SKILL.md

Drop 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