ranklint — complete reference
SEO toolkit for Nuxt 4: sitemap/robots/JSON-LD generation out of the box, a live SEO linter in DevTools, and regression control in CI. Everything it does, every setting, every nuance.
Packages & architecture
- @ranklint/nuxtModule: sitemap, robots, useJsonLd, DevTools tab
- @ranklint/cliThe
ranklintcommand: crawler (Playwright), Lighthouse, monitor, watch - @ranklint/coreEngine: types, crawler, runner, config, diff, storages — no Nuxt, no Playwright
- @ranklint/checks42 rules + Schema.org Zod schemas
- @ranklint/reportersmarkdown, json, junit, gitlab, html, github + diff and alerts
- @ranklint/devtoolsVue panel for Nuxt DevTools (self-contained bundle)
- @ranklint/preset-default"All built-in rules" preset for
extends
Dependencies point strictly downward: core knows nothing about Nuxt or Playwright — page loading is described by the PageFetcher interface (implementations: HttpFetcher — bare fetch, PlaywrightFetcher — real Chrome). Everything revolves around four types: PageSnapshot (html after hydration + ssrHtml before JS, status, headers, ttfb, links, above-fold images) → Check → Issue (checkId, severity, message, url, selector, suggestion, docs) → Report (formatVersion: 1).
Every feature block is disableable — disabled code is not registered at all. The module's client bundle contribution is 485 bytes gzip (a 5 KB limit is guarded by a CI test).
Nuxt module
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@ranklint/nuxt'],
ranklint: {
site: { url: 'https://example.com', name: 'Example' },
sitemap: {
enabled: true, // false or sitemap: false — the route is not registered
path: '/sitemap.xml',
sources: [ /* three kinds of sources, see below */ ],
cacheTtl: 3600, // in-memory response cache, seconds
},
robots: { mode: 'owner' }, // false — leave robots.txt alone entirely
jsonLd: true, // false — useJsonLd is not registered
devtools: true, // false — the SEO tab is not registered
},
})
Works with zero configuration: /sitemap.xml and /robots.txt are available right after npx nuxi module add @ranklint/nuxt. Requires Nuxt 4 — throws a clear error when compatibilityVersion < 4.
Sitemap: four sources of entries
- Static routes — the module scans
app/pages/**at build time; dynamic files ([id].vue) are excluded. - Static entries —
{ loc, lastmod?, changefreq?, priority? }objects right insources. - URL sources — strings (
'/api/seo/urls'): the route$fetches them on every request. - Function sources —
async () => SitemapSourceEntry[]. Serialized into the server bundle viatoString()into a Nitro virtual module, so the function must be self-contained: Nitro globals ($fetch) are available, closures over nuxt.config variables are not. A non-serializable (bound/native) function fails the build with a clear error.
Nuances: a failing source → console.warn + serve what was collected (a partial sitemap beats a 500). loc is absolutized against site.url, entries are deduplicated, XML is escaped. The path is added to nitro prerender — with nuxt generate sources are queried at build time.
Robots: environment policy
The route is registered only with mode: 'owner'. Environment: NUXT_RANKLINT_ENV → otherwise autodetect (dev, known CI variables). Non-prod → Disallow: / (UAT never leaks into the index), prod → Allow: / + a Sitemap: directive. An explicit override always beats autodetect. For a team that does not own the root robots.txt — ranklint generate robots-fragment.
runtimeConfig
Options live in runtimeConfig.ranklint — overridable per environment via env variables without a rebuild: NUXT_RANKLINT_SITE_URL and friends.
Composables
useJsonLd(type, data)
useJsonLd('Product', {
name: 'Widget',
offers: { price: 9.99, priceCurrency: 'USD' },
})
Renders <script type="application/ld+json"> via useHead with @context/@type. In dev the data is validated by Zod schemas (lazily imported — the validator never reaches the prod bundle) with console warnings. Schemas: Product, Article, BreadcrumbList, Organization, WebSite, FAQPage, Event, LocalBusiness, JobPosting; @graph graphs are supported. The CLI rule jsonld:valid-schema uses the same schema set — no duplication.
useRanklintIgnore([...ruleIds])
Emits <meta name="ranklint:ignore" content="...">; the audit subtracts these rules for that specific page. Works for custom rules too.
The DevTools "SEO" tab
An iframe Vue app, self-contained ~166 KB bundle (Vue runtime, level-1 checks and zod are inlined). Works live: re-checks on navigation and DOM mutations (MutationObserver, 400 ms debounce / 2 s max-wait). The DevTools panel's own DOM is stripped before analysis.
- Issues — 17 page-scope Level 1 rules (no network, no ssrHtml); clicking an issue scrolls to the element with a 1.6 s highlight.
- Links — on-demand check: same-origin links are deduplicated, HEAD with a pool of 5 workers (405 → GET retry), broken ones listed. Zone-aware: the panel pulls zones from
ranklint.config.tsvia the dev endpoint/__ranklint/devtools-zones— links into foreign zones go into a yellow group with azone: namebadge instead of red "broken". - Outline — the h1–h6 tree with problems, click scrolls.
- Meta — a table of all meta on the current page.
- JSON-LD — blocks with schema validation; invalid ones show path and message.
What the panel does not do (use the CLI): site-scope rules (duplicates, orphans, sitemap, robots), indexability:ssr-content, redirect chains, TTFB, Lighthouse. The tab only works with a built @ranklint/devtools — otherwise the module warns and skips registration.
ranklint.config — every field
The file ranklint.config.{ts,js,mjs,json,jsonc} is loaded by c12. TS gets autocompletion via defineRanklintConfig; JSON gets a JSON schema. The whole config is Zod-validated at startup: an unknown rule id is an error with did-you-mean (Levenshtein ≤ 3).
import { defineRanklintConfig } from '@ranklint/core'
export default defineRanklintConfig({
extends: ['./agency-preset.ts', '@ranklint/preset-default'],
site: { url: 'https://example.com', name: 'Example' },
apps: { // multi-app domain zones
self: { paths: ['/en/market/**'] }, // the zone named self is crawled
main: { paths: ['/**'], owner: 'external', checks: ['...'] },
},
rules: {
'meta:title-length': ['error', { min: 30, max: 60 }],
'meta:description-length': 'off', // 'error' | 'warn' | 'info' | 'off' | [severity, options]
},
crawl: {
entry: ['/en/market'], // crawl seed paths (default: the audited URL itself)
concurrency: 5, // parallel tabs
delay: 0, // ms between pages
maxPages: 1000, // stop + truncated: true in the report
ignore: ['/admin/**'], // globs — skip
strategy: 'full', // 'full' | 'sitemap+sample'
userAgent: 'custom UA',
insecureTls: true, // self-signed certs on dev/staging
viewport: { width: 375, height: 812 },
auth: { // staging behind a wall
headers: { 'X-Auth': '...' },
basic: { username: '...', password: '...' },
cookies: [{ name: '...', value: '...' }],
},
},
robots: { // expectations for an EXTERNAL robots.txt
mode: 'owner', // 'owner' | 'external'
expect: {
allow: ['/'], disallow: ['/admin'],
sitemaps: ['https://example.com/sitemap.xml'],
indexable: true,
},
},
lighthouse: {
enabled: true,
runs: 5, // runs per URL
aggregation: 'median', // 'median' | 'p75' | 'best'
formFactor: 'mobile', // 'mobile' | 'desktop'
maxUrls: 5,
thresholds: { '/listing/**': { performance: 90, lcp: 2500 } },
},
customChecks: [ /* defineCheck({...}) */ ],
monitor: {
storage: 'fs', // 'fs' | 's3'
dir: '.ranklint/reports', // for fs
keep: 60, // rotation
bucket: '...', prefix: '...', endpoint: '...', region: '...', // for s3
},
profiles: { // defu-patched over the base: --profile uat
uat: { site: { url: 'https://uat.example.com' } },
},
})
Layer precedence: the config itself > earlier extends entries > later ones. A profile is patched on top of the base. Local paths in extends require a file extension (./preset.ts).
insecureTls belongs only in a dev/local profile — it must never reach a prod audit.
The 42 rules
Each: 'error' | 'warn' | 'off' or [severity, options]; per-page suppression via useRanklintIgnore(). Scope page — per page, site — once for all pages.
| Rule | Scope | Default | Checks / options |
|---|---|---|---|
meta:title-required | page | error | <title> exists |
meta:title-length | page | warn | length; { min, max } |
meta:description-required | page | error | meta description exists |
meta:description-length | page | warn | length; { min, max } |
meta:og-required | page | warn | og:title, og:description, og:image |
meta:twitter-card | page | warn | twitter:card is valid |
meta:no-duplicate-title | site | error | identical titles across pages |
meta:no-duplicate-description | site | error | identical descriptions |
canonical:required | page | error | rel=canonical exists |
canonical:valid | page | error | canonical responds 200 (network) |
canonical:no-chain | site | warn | canonical doesn't point to a page with a different canonical |
| Rule | Scope | Default | Checks / options |
|---|---|---|---|
headings:single-h1 | page | error | exactly one h1 |
headings:no-empty | page | warn | no empty headings |
headings:hierarchy | page | warn | no level skips (h2 → h4) |
headings:h1-length | page | warn | h1 length; { min, max } |
headings:unique-h1 | site | warn | h1s are unique across pages |
| Rule | Scope | Default | Checks / options |
|---|---|---|---|
links:no-broken | page | error | internal links aren't 4xx/5xx (HEAD, cached per run) |
links:no-redirect-chain | page | warn | redirect chains; { maxHops } |
links:permanent-redirects | page | warn | 302 where a 301 belongs |
links:trailing-slash-consistent | site | warn | trailing-slash consistency |
links:no-orphans | site | warn | sitemap pages nobody links to |
| Rule | Scope | Default | Checks |
|---|---|---|---|
hreflang:valid-targets | page | error | hreflang targets respond 200 |
hreflang:symmetric | site | error | hreflang links are reciprocal |
i18n:no-locale-leak | page | error | content language matches URL locale and html lang |
| Rule | Scope | Default | Checks / options |
|---|---|---|---|
jsonld:parseable | page | error | JSON-LD parses |
jsonld:valid-schema | page | error | validity against Schema.org schemas; { schemas } — your own |
images:alt-required | page | warn | content imgs have alt |
images:dimensions-required | page | warn | width/height against CLS |
images:no-lazy-above-fold | page | warn | no loading=lazy in the viewport; { firstImages } |
| Rule | Scope | Default | Checks |
|---|---|---|---|
robots:reachable | site | error | robots.txt responds 200 |
robots:zone-not-blocked | site | error | own zone isn't Disallow'ed |
robots:sitemap-declared | site | warn | Sitemap directive declared |
robots:env-policy | site | error | prod open / non-prod closed |
robots:expected-disallow | site | warn | robots.expect expectations hold |
| Rule | Scope | Default | Checks / options |
|---|---|---|---|
indexability:ssr-content | page | error | content exists in SSR, not only after hydration; { minRatio } |
sitemap:reachable | site | error | sitemap.xml responds 200 and parses |
sitemap:no-noindex | site | error | no noindex pages in the sitemap |
http:no-mixed-content | page | error | no http resources on an https page |
http:no-soft-404 | site | error | 404s aren't masked as 200 |
http:x-robots-consistent | page | error | X-Robots-Tag doesn't contradict meta robots |
http:ttfb-budget | site | warn | p75 TTFB per group; { p75, budgets } |
mobile:viewport | page | error | viewport meta exists |
Special rules outside the registry
links:reachable— links into foreign domain zones are alive (synthesized by the crawler from the reachability queue; configurable viarules).lighthouse:threshold— a threshold from the lighthouse config was violated.crawl:timeout— the page failed to load (warn); its page checks are skipped, no false errors.internal:check-failed— a rule threw (info); one failing check never kills the audit.
Smart heuristics
- i18n:no-locale-leak — two layers: (1)
html langvs the URL prefix locale; (2) the language of the text itself — a body clone without script/style/noscript/template → Cyrillic or Arabic script share >50% (Cyrillic → ru/uk/be/bg/sr/mk/kk, Arabic → ar/fa/ur) or stop-word profiles for en/de/fr/es/it (min 40 letters, best score ≥3 and ≥2× the runner-up; unknown locales aren't flagged). Catches Russian text under/en/even with a correct lang attribute. - images:no-lazy-above-fold — Playwright reports the src of images actually inside the viewport (1280×720); matching is per-instance: a lazy duplicate of the same image in the footer isn't flagged. Without data — fallback to "first 3 imgs".
- http:ttfb-budget — p75 per group: explicit
budgets: {'/listing/**': 500}match by route pattern, remaining pages are grouped automatically (segments with ids/uuids →*); an auto group warns only with ≥2 samples — a single cold start is not a signal. - http:no-soft-404 — HEAD to a deliberately nonexistent path: 200 → the site masks 404s; plus "not found" text patterns on status-200 pages.
- indexability:ssr-content — compares ssrHtml with post-hydration html: an H1 that exists only on the client, or <50% of text present in SSR → an indexing error.
CLI — all commands
ranklint audit
| Flag | Description |
|---|---|
--url | address of a live site |
--start | path to .output/server/index.mjs — starts Nitro itself and stops it after; the server origin overrides site.url, so zones match localhost |
--profile | profile from ranklint.config |
--reporter | markdown (default) | json | junit | gitlab | html | github |
--output | report to a file instead of stdout |
--json-output | additionally the raw json (input for diff) |
--cwd | where to look for ranklint.config |
--mode monitor | monitoring mode (below) |
Exit code 1 on error issues. Without a ranklint.config it runs with a minimal config from the URL origin. Flow: config → custom checks → seeds (crawl.entry paths or the audited URL; with strategy: 'sitemap+sample' — a sitemap sample: group by route pattern, up to 5 random URLs per group) → BFS crawl → checks → crawl-budget analysis (parametric URL groups: how many, how many with canonical/noindex — shows where crawl budget leaks) → Lighthouse → report.
ranklint diff
| Flag | Description |
|---|---|
--base | path to report.json or a git ref — then the report is pulled from CI artifacts |
--current | current report.json |
--reporter | markdown | json |
--output | to a file |
The CI adapter picks by environment: GITHUB_ACTIONS=true → GitHub (artifact by name, zip unpacked by a built-in reader), otherwise GitLab (branch job artifact). Error policy: no CI context → warn + full "first run" report (a missing base is not an error); a download error (401/403/network) → hard fail, so an expired token can't hide behind an eternal first run. Exit 1 only on new error issues.
The rest
| Command | Flags | What it does |
|---|---|---|
lighthouse | --url (req.), --runs, --cwd, --output | standalone LH run with aggregation and config thresholds; exit 1 on violation |
outline | --url (req.), --output, --cwd | markdown tree of h1–h6 for every crawled page — structure review |
watch | --url (default localhost:3000), --pages, --app | live checks on file edits (see Watch) |
history | --dir, --limit (20), --csv, --cwd | trend of stored monitor reports as a table or CSV |
generate robots-fragment | --output, --cwd | a root robots.txt fragment from robots.expect for the owning team |
Zones (multi-app domains)
When several apps from different teams share one domain:
apps: {
self: { paths: ['/en/market/**', '/ar/market/**'] }, // the zone named self is crawled
main: { paths: ['/**'], owner: 'external' }, // the rest is foreign
}
Every URL is classified by the most specific pattern (globs */**): own zone → full crawl; a foreign zone of the same domain → HEAD only, into the reachability queue → the links:reachable rule ("is our link into the other section alive?"); crawl.ignore → skip; external domain → counted, never visited. The same route-pattern matcher powers lighthouse thresholds, ttfb budgets and crawl-budget grouping. The DevTools panel understands zones too (via the dev endpoint). A broken link into a foreign zone is not links:no-broken: not yours to fix, but yours to know about.
Lighthouse
A separate Chrome via chrome-launcher (not Playwright — requires an installed Chrome). N runs per URL, per-metric aggregation: median (default) / p75 / best — a single run is ±10 points of noise. Metrics: performance, seo, accessibility, bestPractices, lcp, cls, tbt + the LCP element with a suggestion. Thresholds are route patterns and become regular error issues (→ exit 1). Results land in Report.lighthouse; diff compares by (url-pattern, metric) — a metric regression between branches is visible. formFactor: 'mobile' also sets the crawler viewport to 375×812 unless crawl.viewport is set.
Diff & CI
Issue key: checkId + url without origin + selector — stable across environments (UAT compares against prod). diffReports → newIssues / fixedIssues / pagesDelta.
Base report storages (ReportStorage): fs (a JSON directory, latest by mtime), GitLab artifacts (read-only, branch job artifact), GitHub artifacts (read-only: list by name, branch filter, zip download — a built-in minimal zip reader: EOCD → central directory → inflateRawSync; corrupt input → null, not an exception), S3 (for the monitor; the SDK is an optional peer, any S3-compatible endpoint).
Ready-made CI presets: presets/gitlab-ci/seo.yml (jobs .ranklint-audit, .ranklint-diff with an automatic MR comment, .ranklint-lighthouse, .ranklint-monitor with Playwright and report caches) and presets/github-actions/seo.yml. The github reporter emits PR annotations.
Production monitoring
ranklint audit --url https://prod --mode monitor — for cron/schedules. Exit is always 0: prod monitoring never fails the pipeline, it alerts instead. Flow: audit → enrichment → diff against the last stored report → save by timestamp → alerts only when newIssues > 0.
- CrUX (
RANKLINT_CRUX_API_KEY) — field Core Web Vitals of the origin: LCP, CLS, INP of real users. - Search Console URL Inspection — up to 10 pages: verdict, coverageState, indexingState, rich-results issues. Auth: a ready token
RANKLINT_GSC_TOKENor a service accountRANKLINT_GSC_KEY_FILE(an RS256 JWT built on node:crypto with zero dependencies, iat shifted −30 s for clock skew). - Alerts: Slack webhook and/or Telegram (bot token + chat id) — payloads built by
slackPayload/telegramText. - Storage: fs (default) or s3 with
keeprotation; trend —ranklint history.
Watch mode
ranklint watch --url http://localhost:3000 — chokidar watches app/pages/** (and the rest of the app dir: editing a layout/composable re-checks recent routes). File → route → HttpFetcher hits the dev server → fast page checks without network rules → ESLint-style output (url + selector + suggestion).
Dynamic routes: [id] → [^/]+, [...slug] → .+, [[x]] — optional segments; sample URLs come from the dev server's /sitemap.xml (understands a 1-level sitemap index, up to 3 URLs per pattern, cache with refetch). No match → watch:no-sample-url; a failing check → watch:error, never a false "clean". No module integration by design — the CLI stays independent.
Custom rules & presets
import { defineCheck } from '@ranklint/checks'
customChecks: [
defineCheck({
id: 'myteam:no-lorem', // clashing with a built-in id is an error with a hint
category: 'meta',
severity: 'warn',
scope: 'page', // 'page' | 'site'
docs: 'https://wiki.myteam.dev/seo/no-lorem',
optionsSchema: z.object({...}), // optional — options validation
async run(ctx) {
// ctx: page, pages, document (linkedom DOM), config, site, fetcher
return [{ checkId: 'myteam:no-lorem', severity: 'warn', message: '...', url: ctx.page!.url }]
},
}),
]
Custom rules are first-class: configured via rules, disabled with 'off' and useRanklintIgnore. The CheckContext contract is stable within a major version. Presets — via extends (an npm package or a local file); @ranklint/preset-default pins the built-in rules at their default severities — a "live" snapshot of the installed checks version.
Env variables
| Variable | Purpose |
|---|---|
NUXT_RANKLINT_ENV | environment override for the robots policy (prod / staging / dev) |
NUXT_RANKLINT_SITE_URL | per-environment override of the module's site.url |
RANKLINT_CRUX_API_KEY | CrUX API (field CWV in the monitor) |
RANKLINT_GSC_TOKEN / RANKLINT_GSC_KEY_FILE / RANKLINT_GSC_PROPERTY | Search Console: token / service-account JSON / property |
RANKLINT_SLACK_WEBHOOK | monitor alerts to Slack |
RANKLINT_TELEGRAM_BOT_TOKEN + RANKLINT_TELEGRAM_CHAT_ID | alerts to Telegram |
CI_API_V4_URL, CI_PROJECT_ID, CI_JOB_TOKEN / RANKLINT_GITLAB_TOKEN, RANKLINT_AUDIT_JOB | GitLab artifacts for diff (set by GitLab automatically, except the token and job name) |
GITHUB_REPOSITORY, GITHUB_TOKEN / RANKLINT_GITHUB_TOKEN, RANKLINT_ARTIFACT_NAME, GITHUB_API_URL | GitHub artifacts for diff (artifact name default: ranklint-report) |
Nuances & limitations
- URL dedup while crawling: the hash is dropped, the query is kept — faceted filters matter for crawl-budget analysis.
- A page that failed to load (timeout, network, TLS) → a snapshot with statusCode 0 +
crawl:timeout; its page checks are skipped — no false "missing title". The crawler as a whole never dies. maxPagesreached →truncated: truein the report — you can see the audit is partial.- The Nuxt dev server is not for full crawls: on-the-fly compilation × parallel tabs = worker OOM. Locally use
--startagainst a build or lowerconcurrency/maxPages; full audits belong on deployed environments. insecureTlssets Playwright'signoreHTTPSErrorsandNODE_TLS_REJECT_UNAUTHORIZED=0for the process — dev/staging profiles only.- Lighthouse requires an installed Chrome (chrome-launcher) and doesn't work with self-signed TLS — run it against deployed environments.
- The above-fold heuristic viewport is Playwright's default 1280×720 (or
crawl.viewport). srcset-only images without src aren't measured. http:x-robots-consistentis part of DevTools Level 1, but the panel has no headers — it's a no-op there.- Sitemap function sources are self-contained: no closures over nuxt.config variables.
- A profile is applied as a defu patch: objects merge, scalars override.
- One monitor run = one JSON; storage keys: timestamp (monitor) or commit SHA (MR).
- DOM is parsed by linkedom (server) / DOMParser (panel) — the same rule code runs in both worlds.
- One failing rule (
internal:check-failed, info) never kills the audit; an unknown rule id in the config does — with did-you-mean.