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
- Imogen Hart (editor), Callum Reid (developer)
- 31 October 2026
- 24 to 30 October 2026, UTC (7 days)
- 137,691 requests, synthetic
- sq4t9wzk: production and staging
- 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
| Domain | What it is | Requests | Share | Response bytes |
|---|---|---|---|---|
| api | Live API | 73,710 | 55.7% | 16.17 GB |
| apicdn | API CDN | 10,775 | 8.1% | 12.13 MB |
| cdn | Images and files | 47,749 | 36.1% | 30.15 GB |
Live API requests by caller
| Method | Endpoint | User agent | Requests | Share | Notes |
|---|---|---|---|---|---|
| GET | query | @sanity/client 7.11.2 | 62,670 | 85.0% | The site’s server: page renders (finding 1), the related route (finding 4), the webhook handler (finding 7) and 630 preview-build requests |
| GET | query | python-requests/2.28.1 | 10,080 | 13.7% | Unknown caller, once a minute (finding 5) |
| POST | query | @sanity/client 7.11.2 | 960 | 1.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.
| # | Query (shortened) | Requests | Median response | Share of bytes | Notes |
|---|---|---|---|---|---|
| 1 | *[_type == "navigation"][0]{..., items[]->} | 15,729 | 187.4 KB | 17.4% | Every page view; whole articles for menu links (finding 2) |
| 2 | *[_type == "siteSettings"][0] | 15,729 | 2.1 KB | 0.2% | Every page view; small, but should be cached |
| 3 | *[_type == "article" && slug.current == $slug][0]{...} | 11,375 | 18.4 KB | 1.3% | Article page; fine apart from the client it uses |
| 4 | *[_type == "article" && references($id)] | 10,775 | 9.6 KB | 0.7% | Related articles, through the preview client (finding 4) |
| 5 | *[_type == "article"] | order(publishedAt desc)[0...20] | 10,080 | 307.4 KB | 18.3% | The unknown caller (finding 5) |
| 6 | *[_type in ["article", "section", "page"]]{_type, "slug": slug.current} | 5,068 | 21.0 KB | 0.6% | The webhook handler listing every path (finding 7) |
| 7 | *[_type == "article"] | order(publishedAt desc) | 2,436 | 4.17 MB | 61.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,548 | 13.5 KB | 0.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:
| Content | Fetched from | Cached in Next.js | Revalidated |
|---|---|---|---|
| Articles | API CDN | Yes, tagged per article | When that article is published |
| Navigation, site settings | API CDN | Yes | When they’re published |
| Home “latest” | API CDN | Yes | When any article is published |
| Related articles | API CDN | Yes, tagged per article | When that article is published |
| Search | API CDN | No | Every request (it’s per query) |
| Draft mode | Live API, preview client | No | Every request, by design |
| Static and preview builds | Live API | Not applicable | Each 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=formatEffort: 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
altfield 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
| Order | Fix | Effort | Effect |
|---|---|---|---|
| 1 | Finding 4: the published client on the related route | An hour | Closes the draft leak |
| 2 | Finding 1: separate published and preview clients | Half a day | Moves published reads, 78.6% of live API requests in the sample, to the API CDN |
| 3 | Finding 2: project the navigation and home queries | Two hours | Shrinks the two queries behind 78.7% of live API response bytes in the sample |
| 4 | Finding 7: webhook off drafts, filtered, revalidate by tag | Half a day | Stops draft saves from querying every path and clearing the cache |
| 5 | Finding 5: switch off the old integration, revoke its token | An hour, once found | Removes 13.7% of live API requests in the sample and a stale token |
| 6 | Finding 6: sized hero images | Half a day | Hero originals were 91.5% of image bandwidth in the sample; faster pages |
| 7 | Finding 3: request tags | An hour | Next month’s logs explain themselves |
| 8 | Content model notes | A day, with the editors | Cleaner 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.