Config file
Configure svelte-vitals once with svelte-vitals.config, instead of repeating flags.
Instead of repeating --rules, --ignore, --fail-on, and --weights on every invocation, put them in a svelte-vitals.config file at your project root. The CLI and the Vite plugin both read it automatically. The plugin reads it directly; see Using the config file with the Vite plugin below.
Run svelte-vitals install --client config-file to scaffold one with every option below commented out.
Where it lives
svelte-vitals looks for one of these, in this order, in the analyzed directory only, with no upward search into parent directories. The analyzed directory is the SvelteKit project root, the same place vite.config.* lives.
svelte-vitals.config.jssvelte-vitals.config.ts
The first match wins. If neither exists, svelte-vitals runs with its built-in defaults.
install --client config-file picks .ts when both hold: the project looks TypeScript-oriented (tsconfig.json or vite.config.ts at the root), and svelte-vitals is a declared dependency. The defineConfig import resolves at load time, so an npx-only project must not get it.
Otherwise it writes .js. Both files are ESM, so the project must be "type": "module", which is SvelteKit’s default; CommonJS projects are not supported. --force keeps an existing file’s extension rather than switching format underneath you.
Pass --config <path> to analyze under a specific config file instead. svelte-vitals then skips
discovery entirely rather than merging with it. A relative path resolves against the directory you run the
command from, never the analyzed directory, so from a repo root
svelte-vitals apps/web --config shared/sv.config.js reads ./shared/sv.config.js. It accepts
.js and .ts only, and a missing or unreadable file exits 2. Reach for it to try a config out
before committing it, or to share one config across the apps in a monorepo.
--config is a CLI flag only. The Vite plugin keeps resolving the config
from the cwd passed to svelteVitals({ ... }), else the Vite config root
(see below). To share a config with the plugin too,
import the shared file in vite.config.ts and spread it into the plugin’s options, which take
precedence over the discovered file.
Example
// svelte-vitals.config.ts
import { defineConfig } from 'svelte-vitals';
export default defineConfig({
treatDynamicAs: 'warn',
metaComponents: ['Seo'],
rules: {
'seo/json-ld': 'off'
},
failOn: 'warning',
weights: {
seo: 2
}
});// svelte-vitals.config.js
export default {
treatDynamicAs: 'warn',
metaComponents: ['Seo'],
rules: {
'seo/json-ld': 'off'
},
failOn: 'warning',
weights: {
seo: 2
}
};defineConfig is a thin helper that merges your object over the built-in defaults; it buys type-checking and autocomplete in a .ts file and nothing else. A bare export default {...} behaves identically at runtime, and unlike the import, it works when svelte-vitals is only ever run via npx.
That import is a runtime one. It resolves when svelte-vitals loads the file, so svelte-vitals must be a declared dependency. Import it from svelte-vitals, not @svelte-vitals/core: core is normally transitive, and a strict node_modules layout (pnpm’s default) won’t resolve it.
Available options
| Option | Type | Default | Description |
|---|---|---|---|
treatDynamicAs |
'pass' | 'warn' | 'fail' |
'pass' |
How to score routes where a metadata value is set dynamically |
metaComponents |
string[] |
[] |
<head>-metadata components the analyzer cannot resolve (e.g. from an npm package); it follows resolvable in-repo components automatically |
rules |
Record<string, 'off' | 'critical' | 'warning' | 'info' | { severity?, options? }> |
{} |
Per-rule overrides: disable a rule, change its severity, or set its options |
failOn |
'critical' | 'warning' | 'info' |
'critical' |
Minimum severity that fails the run (exit code 1) |
weights |
Partial<Record<Category, number>> |
every category 1 |
Per-category weights for the combined Health score |
overrides |
RuleOverride[] |
(none) | Route- and file-scoped rule overrides; see below |
Category is 'seo' | 'performance' | 'correctness' | 'security' | 'architecture' | 'a11y'.
A weight of 0 is valid. It excludes that category from the Health average entirely; the category’s own score still appears in the output, and findings and exit-code behavior are unaffected. Setting every present category to 0 is an error, though: there is nothing left to average, so the run stops with exit 2.
Scoping rules to routes or files (overrides)
rules applies everywhere. overrides applies rule settings only where they match. The typical
case is routes that are intentionally not public, behind auth, and shouldn’t be held to SEO
metadata rules:
export default {
overrides: [
// Everything in the (app) route group: no SEO checks at all.
{ files: 'src/routes/(app)/**', rules: { seo: 'off' } },
// /admin and everything under it: keep seo/title-presence, but only as info.
{ route: '/admin/**', rules: { 'seo/title-presence': 'info' } }
]
};
Each entry has rules (keys are rule ids or category names, values are
'off' | 'critical' | 'warning' | 'info' or the object form described in
Rule options below) plus at least one scope:
routematches globs against the finding’s route id as shown in reports, e.g./blog/[slug]. SvelteKit(group)segments are not part of the route id:src/routes/(app)/dashboardreports as/dashboard. To target a group, usefiles.filesmatches globs against the finding’s source path, e.g.src/routes/(app)/dashboard/+page.svelte.
Glob syntax is deliberately small: * matches within a path segment, ** matches across
segments, and a trailing /** also matches the bare prefix (/admin/** matches /admin
itself). Everything else is literal, including (, ), [ and ]. An entry matches when
any of its route or files globs match.
Semantics worth knowing:
'off'removes matching findings entirely. They don’t fail the run and don’t drag the score, as if the rule hadn’t run there. A severity value re-classifies instead.- svelte-vitals evaluates entries in order and later entries win; within one entry, a rule-id key
beats a category key when it specifies a severity. An options-only rule-id key (no
severity) contributes its options but leaves the category key’s severity in force; it doesn’t shadow it. Overrides always win over the globalrulessetting where they match. - Findings that aren’t attached to a route or file, project-wide checks like robots.txt, are
never affected. Use
rulesfor those. overridesapplies to the CLI and to the Vite plugin’s build-time gate. The live dashboard’s per-request layer scores each page as it renders and does not apply them. Use the CLI, or the build gate, as the source of truth for an override you have just added.- For a permanent “this part of the app is exempt” policy, prefer
overridesover the suppressions file: suppressions accept only the findings that existed when the file was written, so newly added routes fail again; overrides keep matching new routes under the same glob.
Rule options
Beyond the bare severity string, a rule setting can be an object, { severity?, options? }:
export default {
rules: {
'architecture/prop-count': { options: { max: 10 } },
'performance/heavy-import': { options: { packages: { 'chart.js': 'import chart.js/auto' } } }
},
overrides: [{ files: 'src/lib/**', rules: { 'architecture/prop-count': { options: { max: 4 } } } }]
};
severity is optional. Omit it to keep the rule’s built-in severity while only changing its
options. { severity: 'off', ... } disables the rule as usual, and any options alongside it are
inert. The bare string forms ('off', 'critical', 'warning', 'info') still work unchanged;
the object form is additive, not a replacement for them.
Options work inside overrides entries the same way they work in the top-level rules map,
scoped by route / files like any other override setting, but only on a rule-id key, never
on a category key. A category may carry a severity, but its options are meaningless without
knowing which rule they belong to.
Layers combine in order: built-in default, rules, then each matching overrides entry. How
they combine depends on the option kind:
- integer replaces; the last one set wins.
- list and map add to the built-in set rather than discarding it, so later svelte-vitals releases can still grow the defaults under you.
- map with an existing key keeps the entry but takes your value, which is how you reword the built-in advice for one package instead of only adding new ones.
Only some rules take options. Passing options to a rule that doesn’t accept any is a fatal
config error, same as an unknown option name or a value of the wrong type. The rules below do;
every other rule takes a bare severity/off only. Each rule’s own page has a “Configuration”
section with its exact option names and defaults.
architecture/prop-countandarchitecture/component-sizetake an integermax.seo/title-lengthandseo/description-lengthtake integerminandmax.performance/heavy-importtakes apackagesmap of package specifier → remediation advice, added to the built-in list.performance/preconnecttakes anoriginslist of hostnames, added to the built-in list.architecture/private-scope-importtakes ascopeslist of globs naming private directories. The rule is inert until you set it.architecture/unit-entry-filetakesunits,pascalCaseUnitsandexcludeglobs declaring which directories are units. The rule is inert until you set them.architecture/directory-namingtakes adirectoriesmap of directory glob to casing set, plusexcludeglobs. The rule is inert until you setdirectories.architecture/reserved-directory-namestakesscopes(directory glob → allowed child names),unitScopes(root glob → the names a unit’s children may take),anyCaseUnitScopes(the same, for units of either case) andexclude. Off until a scope map is set.architecture/route-component-importtakes anexemptImportersglob list of satellite files (stories, tests, specs) allowed to import a route entry by hand, added to the built-in list.architecture/doc-link-targettakes aurlRootslist of URL prefixes that stand for the project’s root. The rule is inert until you set it.
Import aliases
Rules that follow imports (architecture/private-scope-import, architecture/route-component-import,
security/shared-state-import, security/handler-state-write) resolve specifiers through the aliases your project declares in
svelte.config.{js,ts}: kit.alias, plus kit.files.lib when $lib has been moved. svelte-vitals
reads them statically, in the same order SvelteKit builds them, and the first matching alias wins,
exactly as it does at build time.
The CLI’s static mode also follows these aliases when it walks component imports to resolve a route’s
<head> and headings (a <title>/meta/JSON-LD/<h1> set by a component imported through $components
or another custom alias, not just $lib or a relative import). Every SEO rule reading that channel
therefore sees content the same way regardless of which alias reached it.
The following cases are not resolved:
- an alias whose value is computed (
path.resolve(...), a template literal) rather than a plain string; svelte-vitals treats the specifier as one it cannot see, so nothing is reported for it; - an alias whose value is a literal absolute path, such as a POSIX path like
/opt/shared/srcor a Windows drive-letter path likeC:\shared\src, since that names a file outside the analyzed project; - a
kit.files.libthat is itself computed rather than a plain string, which makes$libunresolvable too: every rule above falls silent on$lib/...specifiers, andsecurity/handler-state-write’s libserver/exemption stays off rather than guessing at where the directory now points; - a project that passes its SvelteKit options to the
sveltekit()plugin invite.config.tsinstead ofsvelte.config.{js,ts}, with the same result, nothing reported; - a
kit.aliasobject containing a spread ({ ...shared, '$a': 'src/a' }) or a computed key ({ [key]: 'src/a' }); an unknown key could shadow any entry declared after it, so svelte-vitals discards the whole ofkit.aliasrather than just that one entry. Every literal alias declared alongside it stops resolving too ($libis unaffected, since nothing can shadow the position it always occupies).
Precedence
For each field, the first of these that is set wins: CLI flag > config file > built-in default. This is per field, not all-or-nothing. A one-off --fail-on info does not discard the rest of your config file.
One exception: --rules and --ignore are selection, not configuration. Each decides which rules run, not how the ones left enabled are set up. --rules narrows the run to the rule ids it names and overrides an 'off' in the config file’s top-level rules for those ids, since turning a rule off is itself selection; the severity and options the file declared for a named rule are otherwise inherited unchanged. --ignore differs only in direction. It adds off entries for the rule ids it names, layered on top of whatever rules already resolved to, either the config file or --rules’s narrowed selection. A rule it doesn’t name is untouched, keeping whatever that previous layer resolved to: the config file’s configuration, or 'off' if --rules excluded it. --ignore beats --rules when both name the same rule. An explicit rules value passed programmatically or as a Vite plugin option still replaces the config file’s rules map as a whole.
overrides has no CLI flag; route policy belongs in a committed file. The config file is its only source for the CLI and the action; the Vite plugin additionally accepts it as a plugin option (option > file, as usual).
Validation
- Invalid and svelte-vitals stops (exit
2): the file can’t be loaded (syntax error or no default export); an unknown rule id insiderules; an unknown category or a negative/non-numeric value insideweights; a malformedoverridesentry (not{ route/files, rules }-shaped, a scope that isn’t a string / non-empty string array, or a key in itsrulesthat is neither a known rule id nor a category); an invalid rule setting, in eitherrulesor anoverridesentry’srules, meaning a bare string other thanoff/critical/warning/info, an object form with an unrecognized key or an invalidseverity, options on a rule that takes none, an unknown option name, an option value of the wrong type, out of range, or not in the grammar the option declares (a tag-name list given a selector), options on a category key, or (for a rule with both aminand amaxoption) a configured range whereminwould exceedmax. - Invalid but ignored, with a warning (analysis still runs): an unrecognized
treatDynamicAsorfailOnvalue (falls back to flag/default); an unrecognized top-level key (forward-compatible with future config fields).
TypeScript configs
svelte-vitals.config.ts works out of the box on every supported Node (the floor, 24.16+, strips TypeScript types natively with no flag).
Using the config file with the Vite plugin
@svelte-vitals/vite reads svelte-vitals.config.* the same way the CLI does, with no extra wiring. Both the build gate and the live dashboard resolve it from the project root (cwd passed to svelteVitals({ ... }), else the Vite config root), with the same per-field precedence: plugin option > config file > built-in default. weights included.
import { sveltekit } from '@sveltejs/kit/vite';
import { svelteVitals } from '@svelte-vitals/vite';
export default {
plugins: [sveltekit(), svelteVitals({ report: 'console' })]
};
With a svelte-vitals.config file in the project root, the plugin above picks up its treatDynamicAs / metaComponents / rules / failOn / weights / overrides automatically. There is no need to import the file yourself in vite.config.ts. Non-fatal config-file warnings (unknown top-level keys, invalid enum values) are logged to the console with a svelte-vitals: prefix, the same wording the CLI uses.