sanity-plugin-link-checker

Scans a Sanity dataset for broken document references and external links, with a Studio tool to review and jump to the offending documents.

By Kodamera & Henrik Larsson

Install command

npm i sanity-plugin-link-checker

sanity-plugin-link-checker

Built by Kodamera

npm version CI License: MIT

Finds broken links and references across a Sanity dataset and shows them in a Studio tool, with one click to jump straight to the offending document. Dangling-reference checks work out of the box. Accurate external-link checks need the included Document Function — one command to deploy — since browser-only checks are CORS-limited and mostly come back unverifiable rather than a real answer.

The Link Checker tool showing broken links and dangling references

Table of contents

Features

  • Finds broken external links — every URL in your content, checked over HTTP with real status codes. Needs the included Document Function (or CLI) — the Studio's "Run scan" button alone is CORS-limited and can't give you real answers.
  • Catches more than dead links — syntactically malformed URLs and links to localhost/private-network hosts are flagged by default, no network request needed; domain-shaped text missing its http(s):// and URL-shaped text with no link attached are opt-in checks (see Configuration).
  • Finds dangling references — reference fields pointing at documents that no longer exist, checked against both published and draft versions.
  • Scans everything — published documents, drafts, and release versions across the entire dataset.
  • Jump straight to the problem — click any finding to open the document with the offending field focused and scrolled into view.
  • One shared report — results are stored in the dataset itself, so every teammate and environment sees the same scan, live, with no manual steps.
  • Mark as resolved — acknowledge findings you've handled; the marks survive re-scans.
  • CI-ready CLI — fail a build when broken links are found, export the report as JSON.
  • Zero schema pollution — the report document is not a registered schema type and never shows up in your content lists.
  • Extensible — plug in your own URL checker (e.g. a proxy), or use the React-free scanning core in your own scripts and Functions.

Site crawlers and SEO suites (Ahrefs, Screaming Frog, Dr. Link Check, ...) find broken links by crawling your rendered pages. That works — but it reports the symptom, not the source. This plugin works on the content itself, which changes what's possible:

  • Catch problems before they're published. Crawlers only see live pages. This scans drafts and release versions too, so a dead link in tomorrow's landing page never ships.
  • Fix in one click, not one investigation. A crawler tells you page X has a broken link; someone still has to figure out which document and which field that came from. Here every finding opens the document with the offending field focused.
  • Dangling references are invisible to crawlers. A reference to a deleted document usually doesn't render as a broken link — it renders as silently missing content. Only a content-side check can find it.
  • Editors work where they already are. Findings live in the Studio with document previews, draft/published status, and a resolve workflow — no separate tool, login, or CSV export.
  • Free and unlimited. No per-site subscription, no page quota; runs in your own infrastructure (browser, CLI, or a Sanity Function).

It's a complement, not a replacement: crawlers still cover what only rendered pages can show (redirect chains, links added by frontend code, orphan pages) and off-site SEO (backlinks, rankings). This covers the half they can't see — the content before it becomes a page.

Installation

npm install sanity-plugin-link-checker

Quick start

  1. Add the plugin to sanity.config.ts (or .js):

    import {defineConfig} from 'sanity'
    import {linkChecker} from 'sanity-plugin-link-checker'
    
    export default defineConfig({
      //...
      plugins: [linkChecker()],
    })
  2. Open the new Link Checker tool in the Studio menu and click Run scan.

  3. Deploy the Document Function — one command, see the next section.

    Do this one. Without the Function, external checks run from the browser and are CORS-limited — most links come back unverifiable instead of a real broken/ok answer. The Function is what makes external-link checking actually work.

Results are stored in the dataset as a single, always-overwritten document, so every environment (local Studio, deployed Studio, teammates) sees the same scan with no manual step. See Why browser checks are limited (CORS) for the full explanation.

Accurate external checks: deploy the Document Function (once)

Clicking "Run scan" writes a small trigger document (linkCheckerTrigger) alongside the report. Deploying a Sanity Document Function that reacts to it makes the button fully server-side.

  1. npx sanity-plugin-link-checker init-function
  2. Add the printed resource to sanity.blueprint.ts (the command prints the exact snippet; if you don't have a blueprint file yet, it prints the npx sanity blueprints init step too).
  3. npx sanity blueprints deploy

Once deployed, every "Run scan" click reruns the scan server-side (no CORS, real status codes) and the results replace the browser-run ones live, within a few seconds — no reload, no CLI to remember. Functions run on Sanity's included free tier for typical link-checking volumes (20K GB-seconds + 500K invocations/month, included on all plans).

Upgrading an already-deployed Function

The scaffolded Function occasionally changes as the plugin evolves — most notably, Functions scaffolded before the scan-scope config existed call runScan(client, {}, 'function') and silently ignore every option you set in linkChecker({...}) (the browser pass filters correctly, then the Function overwrites the report unfiltered). After upgrading the plugin, refresh your Function:

npx sanity-plugin-link-checker init-function --force
npx sanity blueprints deploy

--force overwrites functions/link-checker-scan/index.ts with the current template. If you customized the file, diff it first and port your changes.

CLI (CI pipelines and no-Function setups)

If you can't or don't want to deploy the Function, the same accurate Node-side scan is available as a one-shot CLI — run it manually, on a cron, or as a CI step. It writes the same report document the Studio tool reads, so results still show up in the tool.

npx sanity-plugin-link-checker \
  --project-id yourProjectId \
  --dataset production \
  --token $SANITY_AUTH_TOKEN

--token needs write access (e.g. an Editor-role API token), since it upserts the report document. --project-id and --dataset also read from SANITY_STUDIO_PROJECT_ID / SANITY_STUDIO_DATASET env vars if you already have those set, as most Studio projects do.

Use --fail-on-findings to make a CI job exit non-zero when broken links/references are found, and --out <path> for a local JSON copy in CI logs:

npx sanity-plugin-link-checker --token $SANITY_AUTH_TOKEN --fail-on-findings --out report.json

Run npx sanity-plugin-link-checker --help for all options (--concurrency, --timeout, --host-delay, --exclude-types, --exclude-urls, --exclude-url-pattern, --ignore-drafts-older-than, --api-version).

Configuration

linkChecker({
  concurrency: 4, // max concurrent external URL checks
  timeoutMs: 8000, // per-request timeout
  hostDelayMs: 1000, // min gap between two requests to the same host
  excludeTypes: ['siteSettings'], // document types to skip entirely
  excludeUrls: ['linkedin.com'], // URLs to skip (substring or RegExp)
  ignoreDraftsOlderThanDays: 90, // skip abandoned never-published drafts
  skipInternalHostCheck: false, // flag links to localhost/private-network hosts
  internalHostPatterns: ['staging.example.com'], // extra hostnames to flag as internal
  detectBareDomains: false, // also flag domain-shaped values missing http(s)://
  detectUnlinkedUrls: false, // also flag URL-shaped text in rich text with no link attached
  apiVersion: '2024-01-01', // Sanity client API version
  checkUrl: async (url) => ({status: 'ok'}), // optional override, see Advanced below
  structureToolName: 'structure', // structure tool name, if renamed
})
OptionTypeDefaultDescription
concurrencynumber4Max concurrent external URL checks
timeoutMsnumber8000Per-request timeout
hostDelayMsnumber1000Min ms between two requests to the same host - avoids tripping rate limiters
excludeTypesstring[][]Document types to skip entirely; sanity.* system types are always skipped
excludeUrls(string | RegExp)[][]External URLs to skip - a string matches as a substring, a RegExp against the full URL. Useful for hosts that block automated checks (LinkedIn, ...)
ignoreDraftsOlderThanDaysnumberoffSkip never-published drafts whose last edit is older than this many days. Drafts of published documents always scan, whatever their age
skipInternalHostCheckbooleanfalseSkip flagging links to localhost/private-network hosts. Only turn on if your project genuinely serves internal-only content that's expected to link to private addresses
internalHostPatterns(string | RegExp)[][]Extra hostnames to flag as internal, beyond the built-in loopback/private/link-local ranges - e.g. your own staging subdomain
detectBareDomainsbooleanfalseAlso flag string values that look like a domain but are missing http:///https:// (e.g. a field whose whole value is example.com). Off by default - the domain-shape + real-TLD heuristic is tuned to avoid likely false positives (Node.js, README.md, install.sh, script.py, and ordinary filenames), but can't be made airtight; turn on deliberately and review what it finds
detectUnlinkedUrlsbooleanfalseAlso flag URL-shaped text found inside rich text that has no link annotation attached - text that looks clickable but isn't. Off by default: it surfaces as a warning (Unverifiable tab, never fails --fail-on-findings), since detecting a URL's boundary inside a sentence is necessarily approximate - see note below
apiVersionstring'2024-01-01'Sanity client API version
checkUrlfunctionbuilt-in checkerOverride how a URL is checked (see Custom URL checking via a proxy)
structureToolNamestring'structure'Structure tool name used for "open document" links; only needed if renamed via structureTool({name})

detectUnlinkedUrls only sees rich text built on Sanity's standard block/span Portable Text type - the fixed children/markDefs/marks shape the block editor itself hardcodes, not a per-schema field name. A fully bespoke, hand-rolled rich-text structure (not built on Sanity's standard block array member) is invisible to this specific check, by design - there's no config escape hatch planned for it. A field-name-remapping option was considered and rejected: the standard case has nothing to remap (Sanity's block editor doesn't allow renaming children/markDefs/marks at all), and a genuinely bespoke structure usually differs in more than field names, so a simple mapping wouldn't reliably work anyway.

Advanced

Custom URL checking via a proxy

If you'd rather click "Run scan" in Studio and get accurate external-link results immediately (no separate CLI step), you can point the plugin at a server-side proxy instead — same idea as the CLI (move the fetch off the browser), but as a live endpoint instead of a one-shot script:

linkChecker({
  checkUrl: async (url) => {
    const res = await fetch(
      `https://your-proxy.example.com/api/check-link?url=${encodeURIComponent(url)}`,
      {headers: {'x-proxy-secret': process.env.SANITY_STUDIO_LINK_PROXY_SECRET ?? ''}},
    )
    return res.json() // {status: 'ok' | 'broken' | 'unverifiable', httpStatus?, reason?}
  },
})

A ready-to-deploy example proxy (Vercel-shaped Node function, with SSRF guardrails — blocks loopback/private/link-local hosts and supports an optional shared secret) lives in examples/link-check-proxy. This requires you to host something, unlike the CLI.

Using the scanning logic in your own code

The Function, the CLI, and the Studio plugin all share the same scanning code, exported React-free from sanity-plugin-link-checker/core for use in your own scripts or Functions: runScan, writeReport, readReport, writeTrigger, and summarizeResult (the same broken/unverifiable counting logic the CLI's --fail-on-findings gate uses). See src/core.ts for the full list, including the acknowledgement and trigger-config helpers.

Why browser checks are limited (CORS)

Worth understanding why the Function matters. External link checking is a fetch against each URL. A browser can only read the real HTTP status of a cross-origin request if the target server sends CORS headers back — most ordinary websites don't, since they have no reason to let arbitrary pages read their responses. Without those headers the request either throws a network error or resolves as unreadable, whether the URL is a healthy page or a real 404.

So external checks run straight from the Studio browser tab land on unverifiable (or timeout) rather than a clean ok/broken. This isn't a bug — it's what the browser allows. Node has no such restriction, which is exactly why the Function (and the CLI above) get real status codes.

A browser-side scan: reference checks are exact, external links show as unverifiable with a banner explaining CORS

Adding or overriding a language

The plugin ships English (en-US) and Swedish (sv-SE). Rather than a config option on linkChecker(), translations use Sanity's own i18n.bundles mechanism — register another bundle for the same namespace in your sanity.config.ts and it merges over (or adds to) the built-in strings automatically, no plugin-side setting needed:

import {defineConfig} from 'sanity'
import {defineLocaleResourceBundle} from 'sanity'
import {linkChecker, linkCheckerLocaleNamespace} from 'sanity-plugin-link-checker'

export default defineConfig({
  plugins: [linkChecker()],
  i18n: {
    bundles: [
      defineLocaleResourceBundle({
        locale: 'de-DE',
        namespace: linkCheckerLocaleNamespace,
        resources: {'result.resolve': 'Auflösen', 'result.details': 'Details'},
        // Bundles merge with overwrite: true by default, so a partial resource
        // object (like this one) fills in just the keys you provide — everything
        // else falls back to the plugin's built-in en-US strings.
      }),
    ],
  },
})

This also works to correct or restyle an existing en-US/sv-SE string, or add a locale this plugin doesn't ship at all — as long as it's a locale your Studio itself supports (see Sanity's locale plugins for the full list; translating this plugin into a language the Studio's own chrome doesn't speak yields a half-translated UI). See src/i18n/resources.ts for every translatable key (English), or src/i18n/resources.sv.ts for a full second-locale example.

How results are stored

  • Results are stored in the dataset as a single, always-overwritten document (_id: 'link-checker-report', _type: 'linkCheckerReport').
  • It's not registered as a schema type, so it won't show up in your content lists, and it's exactly one document regardless of how many times you scan.
  • Every environment (local Studio, deployed Studio, teammates) reads that same document, so a scan run anywhere shows up everywhere with no manual step.
  • It's also cached in the browser (per project + dataset) for a fast first paint.

Uninstalling

Removing linkChecker() from sanity.config.ts only stops the plugin's Studio UI and API calls — it doesn't touch the dataset. Two documents are left behind, since neither is a schema type the Studio's own deletion UI would surface:

  • link-checker-report (_type: 'linkCheckerReport') — the scan results
  • link-checker-trigger (_type: 'linkCheckerTrigger') — only present if you use the "Run scan" button with a deployed Document Function

Delete them with the Sanity CLI, from a Studio project directory:

npx sanity documents delete link-checker-report
npx sanity documents delete link-checker-trigger

Or via @sanity/client:

await client.delete('link-checker-report')
await client.delete('link-checker-trigger')

License

MIT © Kodamera

Develop & test

This plugin uses @sanity/plugin-kit with default configuration for build & watch scripts.

See Testing a plugin in Sanity Studio on how to run this plugin with hotreload in the studio.

Release New Version

Run "CI & Release" workflow. Make sure to select the main branch and check "Release new version".

Semantic release will only publish from configured release branches, so it is safe to run the workflow from any branch.