Skip to content

Vite, and sites

This guide shows you how to start every Vite config in a workspace from one base, so a dev server, a test and a build all resolve the workspace the same way, and how a site’s build ships compressed copies that a static host can serve.

@wtf/project-vite/config holds the base Vite config of a workspace, baseConfig(). Every Vite config starts from it: an app’s vite.config, a Storybook’s viteFinal, and each Vitest config (the configs of @wtf/project-vitest start from it themselves).

Export of ./configWhat it is
baseConfigThe base config, made of the two layers below: mergeConfig(baseConfig(), yours)
compressThe plugin that writes brotli and gzip copies in a site’s build; baseConfig adds it unless compress: false
conditionsOfThe resolve conditions of a run: @wtf/source first, when reading from source
buildsSiteWhether a run is a site’s build: vite build, with no library (build.lib) and no server (build.ssr)
CLIENT_CONDITIONSVite’s own conditions for browser modules, which come after @wtf/source
COMPRESSIBLEThe extensions of the files a site’s build compresses: text, and WebAssembly
THRESHOLDThe smallest file that gets compressed, 1024 bytes
FROM_DISTWhether the run reads the packages from their build (TEST_FROM=dist)

What the base sets

Every command shares this, whether a dev server, a test or a build:

  • resolve.conditions: @wtf/source, then Vite’s own (module, browser, development|production). A workspace package resolves to its TypeScript through the @wtf/source condition of its export map (SOURCE_CONDITION of @wtf/project-resolve/condition), with nothing built first. With TEST_FROM=dist, as test:dist and test:browser:dist run, there is no @wtf/source, and a package resolves the way a consumer resolves it: to its build.

@wtf/project-vitest’s configs add only what a test needs: an alias from each workspace package to its source, and, in a run from the build, the plugin that loads a package’s own modules from its dist.

Compress a site

A site’s vite build adds one thing that no dev server, test, library build or server build gets. Beside each text file it writes, it writes a brotli copy (quality 11) and a gzip copy (level 9), <file>.br and <file>.gz, using vite-plugin-compression2. A file gets copies only if its extension is in COMPRESSIBLE, it is at least THRESHOLD bytes, and the copy is smaller. Files in public/ get them too.

A static host such as GitLab Pages compresses nothing as it serves. It serves a file’s .br or .gz in its place, to a browser that accepts it, only when that copy is there. Without them, a Storybook or an app with megabytes of script crosses the network uncompressed.

Tune it with baseConfig({ compress: { threshold, extensions, encodings } }), or turn it off with baseConfig({ compress: false }), for a site no static host serves.

Compress what Vite did not write

A site’s folder can hold files Vite did not build: Storybook’s manager, which Storybook copies in as it ships, a folder its staticDirs serves, and whatever a pipeline copies in after the build. Vite writes no copies of those; compress them in the job that publishes the folder, after the build, and keep the copies Vite already wrote.

Use it in an app

An app’s vite.config starts from the base and adds its own settings:

vite.config.ts
import { defineConfig, mergeConfig, type PluginOption } from 'vite'
import { baseConfig } from '@wtf/project-vite/config'
// The app's own plugins, as `react()` of @vitejs/plugin-react.
declare const react: () => PluginOption
export default defineConfig(mergeConfig(baseConfig(), { plugins: [react()] }))

mergeConfig appends lists, so the app’s plugins come after the base’s, and any conditions it gives come after @wtf/source.

Use it in Storybook

A Storybook’s viteFinal takes the config Storybook made, merges the base into it, then merges its own settings:

.storybook/main.ts
import { mergeConfig, type InlineConfig } from 'vite'
import { baseConfig } from '@wtf/project-vite/config'
// `StorybookConfig` of the Storybook's framework, as @storybook/react-vite's.
type StorybookConfig = {
stories: string[]
viteFinal: (vite: InlineConfig) => InlineConfig
}
const config: StorybookConfig = {
stories: ['../stories/**/*.stories.tsx'],
viteFinal: (vite) => mergeConfig(mergeConfig(vite, baseConfig()), { plugins: [] })
}
export default config

The base’s conditions replace any resolve.conditions the Storybook set for @wtf/source: don’t repeat them.

Use it with Vitest

Each config of @wtf/project-vitest (rootConfig, packageConfig, integrationConfig, browserConfig, browserRootConfig and storybookConfig) starts from the base, without the site-build plugin. A Vitest config built on one of them needs nothing more. A Vitest config for another suite, such as vitest.host.config.ts, starts from one of them, or from the base.

Which Vite, and which Vitest

@wtf/project-vite owns the Vite of a workspace. It depends on vite at ^8.3.5, and so on rolldown, Vite’s bundler, at the version that Vite takes. @wtf/project-vitest depends on @wtf/project-vite, so a repo gets its Vite through the presets. The config imports only Vite’s types, plus vite-plugin-compression2, the one library that writes the copies.

What a repo’s manifests name:

  • vite: only in a manifest whose own code imports it, such as a vite.config, a Storybook’s .storybook/main.ts or a test that calls build (the root’s manifest, for a config at the root). Name it by @wtf/project-vite’s range, ^8.3.5: never a pin, and never a range that allows an older Vite. pnpm links into a folder’s node_modules only what its manifest names, so a file that imports vite finds it only through a manifest beside it or above it that names it. A repo whose only Vite configs are @wtf/project-vitest’s names no vite at all: Vitest takes it as a peer, and pnpm installs it (through autoInstallPeers, its default) at the one version @wtf/project-vite resolves.
  • rolldown: never. And no override of vite or rolldown in pnpm-workspace.yaml.
  • vitest and @vitest/coverage-v8 at the root, plus @vitest/browser-playwright and playwright in a repo with browser tests: each by one exact version, the one @wtf/project-vitest takes, 5.0.5, since the toolchain is pinned. They are peers of @wtf/project-vitest, never its dependencies. That way the vitest bin (pnpm links only what the root names), the tests’ import { it } from 'vitest', and the coverage provider all come from the very Vitest that runs.

A repo that links these packages from a local folder, before they are published, uses the Vite and rolldown of that folder’s own install. The two match only at one version, so move each repo along with it: pnpm update -r vite rolldown vitest "@vitest/*".