Skip to content

A sample Sanity CMS audit report

Sample report

Sedgeway Quarterly is a made-up online magazine on Next.js and Sanity whose API usage has grown faster than its readership. We generated a synthetic week of its request logs, ran it through Insanity Logs, and wrote the report a full audit produces.

Sample report. Sedgeway Quarterly, its people and its project are made up. The request log is synthetic: we generated it for this sample, with seven problems planted in it, and analysed it with Insanity Logs. Every figure below comes from that run. It is not a real client’s data.

Summary

A Sanity CMS audit finds out why a project’s API requests or bandwidth are higher than its traffic suggests, and what to change. It reads a week of request logs to see which requests hit the live API instead of the API CDN, which queries fetch more than they use, and which callers nobody expected, then traces each one to the code behind it.

For Sedgeway, 87.2% of query requests in the week went to the live API. Almost all of them were published content that the API CDN could have served: the site renders every page with one client set to useCdn: false. Two queries, the navigation menu and the home page’s “latest” list, account for 78.7% of the live API’s response bytes because they fetch whole articles. A script nobody remembers polls the API once a minute, and almost every keystroke in the Studio sets off a webhook that lists every path on the site.

One finding is a security fix for this week: a public route returns draft content. The rest is a day or two of work, in the order set out in next steps.

Method

For
Imogen Hart (editor), Callum Reid (developer)
Date
31 October 2026
Log window
24 to 30 October 2026, UTC (7 days)
Log
137,691 requests, synthetic
Project
sq4t9wzk: production and staging
Analysed with
Insanity Logs

The log. A script with a fixed seed wrote seven days of request-log lines in the format Sanity documents in its request logs data reference: a magazine with about 2,200 page views a day, an editorial team working in the Studio on weekdays, preview builds, and the seven planted problems. The project ID, the .example domains and the IP addresses are all fictional.

The tool. We ran the log through Insanity Logs’ own parsing and analysis code, unmodified, and loaded the same file into the app to check the views agree. The app analyses the file in the browser and never uploads it. Its optional IP location lookup, off by default, is its only external call.

What counts. Cost figures cover billable requests only. Sanity doesn’t count Studio requests, requests from its other applications (tagged sanity.) or responses with a 4xx or 5xx status towards usage, so those are left out: 5,396 Studio requests and 61 failed responses, leaving 132,234. Bandwidth is the responseSize field, the one Sanity meters, in Insanity Logs’ units (1 KB is 1,024 bytes).

The code. A real audit also reads the codebase and the Studio schema, and traces each finding to the line that causes it. For the sample, the causes below are the ones we planted, written up the way the audit would find them.

Headline numbers

  • 87.2% of query requests went to the live API, and 12.8% to the API CDN. For a public site this is usually the other way round.
  • 78.6% of live API requests were reads of published content that a CDN client would serve (computed from the sample).
  • 78.7% of live API response bytes came from two queries that fetch whole articles (computed from the sample).
  • 100% of live API requests were untagged.
  • 91.5% of image bandwidth was hero images at original size, from 18.3% of image requests.

Where the requests went

Billable requests by domain, 7 days
DomainWhat it isRequestsShareResponse bytes
apiLive API73,71055.7%16.17 GB
apicdnAPI CDN10,7758.1%12.13 MB
cdnImages and files47,74936.1%30.15 GB

Live API requests by caller

Live API requests by method, endpoint and user agent
MethodEndpointUser agentRequestsShareNotes
GETquery@sanity/client 7.11.262,67085.0%The site’s server: page renders (finding 1), the related route (finding 4), the webhook handler (finding 7) and 630 preview-build requests
GETquerypython-requests/2.28.110,08013.7%Unknown caller, once a minute (finding 5)
POSTquery@sanity/client 7.11.29601.3%Long search queries: no query identifier and no tag (finding 3)

By tag: 73,710 untagged, 0 tagged. Every live API request is untagged, which is finding 3.

Live API query shapes

GET requests grouped by groqQueryIdentifier, Sanity’s hash of the query without its parameters. Insanity Logs decodes each query from its URL. The share column is each query’s part of the 16.17 GB the live API sent.

Live API query shapes by requests
#Query (shortened)RequestsMedian responseShare of bytesNotes
1*[_type == "navigation"][0]{..., items[]->}15,729187.4 KB17.4%Every page view; whole articles for menu links (finding 2)
2*[_type == "siteSettings"][0]15,7292.1 KB0.2%Every page view; small, but should be cached
3*[_type == "article" && slug.current == $slug][0]{...}11,37518.4 KB1.3%Article page; fine apart from the client it uses
4*[_type == "article" && references($id)]10,7759.6 KB0.7%Related articles, through the preview client (finding 4)
5*[_type == "article"] | order(publishedAt desc)[0...20]10,080307.4 KB18.3%The unknown caller (finding 5)
6*[_type in ["article", "section", "page"]]{_type, "slug": slug.current}5,06821.0 KB0.6%The webhook handler listing every path (finding 7)
7*[_type == "article"] | order(publishedAt desc)2,4364.17 MB61.3%Home page “latest”: every article, no slice, no projection (finding 2)
8*[_type == "article" && section->slug.current == $section] | order(publishedAt desc)[0...24]{...}1,54813.5 KB0.1%Section pages; sliced and projected, fine apart from the client

Rows 1 to 3 include 620 requests from preview builds against the staging dataset, which are fine (see what’s fine).

Findings

Each finding has the evidence from the run, the cause, the fix and the effort. The order to fix them in is under next steps.

1Published content comes from the live API

Evidence

62,670 live API GET requests came from the site’s server user agent. The navigation query ran 15,719 times against the production dataset, once per server render, and every render sent its page queries to the live API as well. Page renders account for 47,157 live API requests, 64.0% of the total; with the related route (finding 4), published reads are 57,932, or 78.6% (computed from the sample).

Cause

The site has one Sanity client, created with useCdn: false, and uses it for every server render. Sanity’s request spike guide describes exactly this: a client with useCdn: false that fetches published content on every render sends a live API request for every query on every page view, so usage grows with traffic instead of being absorbed by the cache. Sanity meters API requests and API CDN requests as separate quotas.

Fix

Two clients: a published client with useCdn: true for every page view, and a preview client for draft mode only.

// lib/sanity.ts
const config = {
  projectId: 'sq4t9wzk',
  dataset: 'production',
  apiVersion: '2025-02-19',
  requestTagPrefix: 'web',
}

// Every page view: cached on the API CDN
export const client = createClient({ ...config, useCdn: true })

// Draft mode only, server only
export const previewClient = createClient({
  ...config,
  useCdn: false,
  perspective: 'drafts',
  token: process.env.SANITY_API_READ_TOKEN,
})

Then cache each query’s result in Next.js and revalidate it when the content changes:

Caching and CDN strategy after the fixes
ContentFetched fromCached in Next.jsRevalidated
ArticlesAPI CDNYes, tagged per articleWhen that article is published
Navigation, site settingsAPI CDNYesWhen they’re published
Home “latest”API CDNYesWhen any article is published
Related articlesAPI CDNYes, tagged per articleWhen that article is published
SearchAPI CDNNoEvery request (it’s per query)
Draft modeLive API, preview clientNoEvery request, by design
Static and preview buildsLive APINot applicableEach build

Static and preview builds can keep the live API: their volume doesn’t grow with visitors, and Sanity recommends the live API where you want the freshest content at build time.

Effort: Half a day.

2Two queries fetch whole articles

Evidence

The navigation query ran 15,729 times with a median response of 187.4 KB: 2.81 GB, 17.4% of the live API’s response bytes, to draw a menu of titles and links. The home page’s “latest” query ran 2,436 times with a median response of 4.17 MB: 9.91 GB, 61.3%. Together, 78.7% of live API response bytes (computed from the sample).

Cause

The menu is a list of references to articles, and items[]-> with no projection returns each whole article, body included. The home query has no slice and no projection, so it returns every article ever published.

Fix

Project only what each page draws, and slice the list:

// Navigation: before
*[_type == "navigation"][0]{..., items[]->}

// Navigation: after
*[_type == "navigation"][0]{
  "items": items[]->{ title, "slug": slug.current }
}

// Home "latest": before
*[_type == "article"] | order(publishedAt desc)

// Home "latest": after
*[_type == "article"] | order(publishedAt desc)[0...12]{
  title, "slug": slug.current, publishedAt, excerpt, hero
}

Both change only when an editor publishes, so cache them (finding 1’s table). The section query already follows this pattern.

Effort: Two hours.

3No request tags

Evidence

All 73,710 live API requests were untagged. Insanity Logs’ insights flag it too: “100.0% of queries are untagged”. The 960 POST search queries had no groqQueryIdentifier, so they can’t be grouped at all.

Cause

Neither the client nor the queries set a tag. Sanity’s log reference says the identifier is empty for POST requests and points to request tags as the way to group them; the client sends a query as POST once it is long enough.

Fix

Set requestTagPrefix on each client (finding 1’s snippet does) and a tag on each query. The client joins them with a dot, so this appears in the logs as web.nav:

const nav = await client.fetch(NAV_QUERY, {}, { tag: 'nav' })

Use one tag per part of the site: nav, settings, article, latest, section, search, related, revalidate.

Effort: An hour.

4A public route returns draft content (security)

Evidence

Filtering the log for perspective=drafts finds 10,775 requests, all to the live API, all one query, *[_type == "article" && references($id)], all from the site’s server. That is the same count as the browser’s “most read” requests, which run once per article view: the related route runs on every article page for every reader.

Cause

The related-articles route, /api/related, imports the preview client: drafts perspective, read token, no CDN. Anyone who calls it with an article ID gets that article’s related drafts, unpublished titles and slugs included. Every call is also a live API request: Sanity’s docs say a client with the drafts perspective queries the live API even when useCdn is true.

Fix

This week: switch the route to the published client. Then check that every import of the preview client sits behind draft mode, and add a lint rule so it can’t be imported anywhere else. The token never reached the browser, so it doesn’t need rotating, but rotate it if there’s any doubt.

Effort: An hour.

5An unknown caller polls the live API every minute

Evidence

One IP address, 203.0.113.47, sent 10,110 requests: one every 60 seconds (the median gap), around the clock, from the first minute of the window to the last. User agent python-requests/2.28.1, no referer, no tag, and API version 2021-10-21, the only pre-2023 version in the log, which Insanity Logs marks as outdated. 30 requests got a 503 and were retried a second later, so 10,080 were billable. Each returned the 20 latest articles in full, 307.4 KB at the median: 2.95 GB, 18.3% of live API response bytes. Insanity Logs’ “suspicious IP activity” insight lists this address first.

Cause

The query and the API version match the newsletter integration Sedgeway used in 2024. Callum thinks it was replaced and never switched off. The project’s API settings still list a read token named for it.

Fix

Find and switch off the integration, then revoke its token from the project’s API settings. If the newsletter still needs a feed, point it at the site’s RSS feed instead.

Effort: An hour, once found.

6Hero images are served at original size

Evidence

8,751 image requests, 18.3% of image requests, asked for an original file with no parameters. The median was 3.23 MB, and together they came to 27.58 GB, 91.5% of image bandwidth. The site’s other 38,998 image requests were sized, with a median of 32.2 KB.

Cause

The article template builds the hero image URL by hand, without the image URL builder the rest of the site uses. Sanity’s image transformation docs say a URL with no parameters serves the original asset, which can use a lot of bandwidth.

Fix

Use the URL builder with a width per breakpoint and auto=format, which returns the best format the browser supports, or Next.js’s image component with a Sanity loader:

urlFor(article.hero).width(1200).auto('format').url()
// https://cdn.sanity.io/images/sq4t9wzk/production/...jpg?w=1200&auto=format

Effort: Half a day.

7Every draft save sets off a full-site revalidation

Evidence

Webhook deliveries don’t appear in request logs, but the handler’s query does. The Studio sent 5,068 draft commits in the week and 43 publishes. A query listing every article, section and page path ran 5,068 times, 95.2% of them within 60 seconds of a commit: about 118 runs per publish. It follows the editors’ week: 180 and 222 commits on the Saturday and Sunday, between 828 and 1,063 on each weekday. It is 6.9% of live API requests.

Cause

The revalidation webhook has its drafts setting switched on and no filter, so every draft change in any document is a delivery. Sanity’s webhook docs warn about exactly this: webhooks ignore drafts by default, and with drafts on, almost every keystroke in the Studio is an update. The handler then lists every path and revalidates them all, which clears the cache finding 1 sets up.

Fix

Switch the drafts setting off, so only published changes trigger it. Filter it to the types the site renders, project just what the handler needs, and revalidate by tag for the document that changed:

// Filter
_type in ["article", "section", "page", "navigation", "siteSettings"]

// Projection
{ _type, "slug": slug.current }

Effort: Half a day.

Content model notes

These come from reading the Studio schema, not the log, so they carry no figures.

  • Tags are free-text strings on each article, so the same tag turns up as “Climate”, “climate” and “Climate crisis”. Make tags a document type and reference them.
  • Related articles are hand-picked as references, and also computed with references($id). Pick one. The hand-picked list is what editors expect.
  • Image alt text isn’t required. Add a required alt field to the image object, with a validation message editors will understand.
  • The article body allows any block type, raw HTML embeds included. Limit it to the blocks the front end renders.

What’s fine

  • The “most read” widget queries from the browser through the API CDN with no token: all 10,775 API CDN requests, 12.13 MB in total.
  • Body images and listing thumbnails go through the URL builder with a width and auto=format: 38,998 requests at a median of 32.2 KB.
  • Preview builds query the staging dataset on the live API, which is right for builds: 630 requests across 10 builds.
  • The section query is sliced and projected: 13.5 KB at the median.
  • Errors are rare: 61 failed responses in the week, 31 image 404s and the poller’s 30 503s.
  • The site’s own clients use API version 2025-02-19.
  • No sign of abuse. Insanity Logs’ security analyser flags many reader addresses for a high request rate: 196 threats, 101 of them critical. We checked them: each is one page load, a handful of image and query requests in under a second.

Next steps

Fix order
OrderFixEffortEffect
1Finding 4: the published client on the related routeAn hourCloses the draft leak
2Finding 1: separate published and preview clientsHalf a dayMoves published reads, 78.6% of live API requests in the sample, to the API CDN
3Finding 2: project the navigation and home queriesTwo hoursShrinks the two queries behind 78.7% of live API response bytes in the sample
4Finding 7: webhook off drafts, filtered, revalidate by tagHalf a dayStops draft saves from querying every path and clearing the cache
5Finding 5: switch off the old integration, revoke its tokenAn hour, once foundRemoves 13.7% of live API requests in the sample and a stale token
6Finding 6: sized hero imagesHalf a dayHero originals were 91.5% of image bandwidth in the sample; faster pages
7Finding 3: request tagsAn hourNext month’s logs explain themselves
8Content model notesA day, with the editorsCleaner content, fewer surprises

The percentages in that table are shares of this synthetic log, not a forecast. The real effect is measured from the next log export after the fixes ship.

  • A 30-minute walkthrough call on this report and what to do first.
  • The report is written so Callum can make every fix. If Sedgeway would rather we did, that’s the audit and implementation option.
  • The log export is deleted now the report has shipped.

To run the free version on your own project, export a week of request logs from Sanity and open them in Insanity Logs. For the full report, with the code traced and the fixes written up, see the Sanity CMS audit.

Sanity behaviour in this report was checked against Sanity’s documentation on 31 October 2026: the request logs data reference, diagnose an API request spike, API CDN, request tags, webhooks and image transformations.

Have something to build?

Tell us what you're working on. We'll tell you how we'd approach it.

We reply within one working day.