Content audits
How to Build a Content Inventory and Keep It Current
Build a content inventory from four sources, reconcile them into one row per canonical URL, choose the columns that earn their place, and keep it current with change triggers instead of a yearly rebuild. Template included.
10 min read
A content inventory is a list of every page on your site, one row per URL, with the facts you need to manage it: status, type, topic, owner, dates and performance. This guide shows how to build one from four sources, reconcile them into a single trustworthy list, choose the columns that are worth maintaining, and keep it current with change triggers instead of rebuilding it every year. A column template and a worked example are included.
An inventory records what exists. It does not judge it: that is the audit, covered in how to audit website content at scale. Most audits stall at this first step, because nobody trusts the list. A spreadsheet exported from the CMS misses landing pages, a crawl misses pages nothing links to, and the sitemap still lists pages retired two years ago. The method below treats the disagreements between those sources as findings, not noise.
Inventory, audit and keyword map: three different files
Teams often try to make one spreadsheet do three jobs, and it ends up doing none of them well. Keep them separate and join them on the URL.
| File | Answers | Changes when |
|---|---|---|
| Inventory | What pages exist, what each one is and who owns it | A page is published, moved, merged or retired |
| Audit | How good each page is and what to do with it | You run an audit, usually once or twice a year |
| Keyword map | Which page should rank for which searches | You plan new content or consolidate old |
The inventory is the one the other two depend on. For the map, see keyword-to-page mapping.
Step 1: Decide the unit and the scope
The unit is one row per canonical URL. Not per article in the CMS, which may publish at several URLs, and not per URL a crawler finds, which includes parameters and duplicates. Google consolidates duplicate URLs under one canonical and reports performance against it (see its canonicalization documentation), so matching that unit makes the later joins line up.
Then decide the edge cases before you collect anything. Each line below is a default that works for most content teams; write down where you differ, so the next person rebuilding the list makes the same choice.
| Edge case | Default | Why |
|---|---|---|
| Redirecting URLs | Keep as rows, with the target, marked as redirects | You need them for redirect hygiene and the next migration |
| Parameter and tracking URLs | Exclude; fold into the canonical row | They are the same page and would double-count |
| Language and market versions | One row each, with a column for the language | Each has its own owner, date and performance |
| Paginated archive pages | Exclude, except page one | Their content is generated from other rows |
| Tag and author archives | Include, marked as archives | They are often thin and indexable, worth a decision |
| PDFs and other files | Include if they get search traffic | Google can index them, and they go stale quietly |
| Subdomains (help center, docs) | Include if a content team owns them | Ownership decides scope, not the hostname |
| noindex pages | Include, marked noindex | Accidental noindex is one of the things you are looking for |
Step 2: Pull from four sources
No single source lists every page. Each one sees the site from a different side, so pull all four and keep a column that says where each URL was found.
| Source | How to pull it | Watch out for |
|---|---|---|
| XML sitemaps | Start at the sitemap index (often listed in robots.txt) and expand every child sitemap | A sitemap file holds at most 50,000 URLs, so large sites split them; plugins often omit page builders and file types |
| A crawl | A crawler starting from the homepage, following links | Only finds pages that something links to; misses pages blocked by robots.txt or behind logins |
| CMS export | A database or API export of published entries, with author, type and dates | Stores slugs, not live URLs; its “modified” date changes on template and plugin updates |
| Search Console | The Pages table of the Performance report, plus the Page indexing report | The interface exports 1,000 rows; use the API, Looker Studio or the bulk export to BigQuery for more |
Two optional sources catch what the four miss. GA4 landing pages show URLs that get visits from any channel, including old campaign pages nobody remembers. A backlink tool shows retired URLs that other sites still link to, which should redirect somewhere useful rather than return a 404.
On Search Console specifically: the Performance report holds 16 months, and query-level rows leave out anonymized queries, but page totals include them. For an inventory you only need page totals, so the page-level numbers are the reliable ones. Google explains the limits in its deep dive on performance data.
Step 3: Reconcile into one row per URL
Before you join anything, normalize every URL the same way, or the same page will appear three times:
- One protocol and one host: https, with or without www, whichever is canonical.
- One trailing-slash convention, matching what your server returns with a 200.
- Tracking parameters removed (utm_, gclid and the like), and fragments after # dropped.
- Redirects resolved: record the URL you found and its final target as separate columns, and join on the final target.
- Case left alone. Paths can be case-sensitive on many servers, so lowercasing them can merge two different pages; flag mixed-case duplicates instead.
Then mark, for each URL, which sources found it. The combination tells you more than any single column. This reconciliation table is the most useful thing a first inventory produces:
| Found in | Usually means | Next step |
|---|---|---|
| All four | A healthy, known page | None; this is your baseline |
| Sitemap, not crawl | Nothing links to it, or the crawl was blocked | Check it as a possible orphan page |
| Crawl, not sitemap | The sitemap generator misses a section or page type | Fix the generator if the pages should be indexed |
| Crawl, not CMS | Page builder, microsite, legacy static page | Find the owner; decide whether it belongs in scope |
| Search Console, nowhere else | Parameter variants, old URLs, or pages you forgot | Check status; redirect or fold into the canonical row |
| Sitemap, but not a 200 | The sitemap lists redirects or errors | Remove them: a sitemap should list the canonical URLs you want indexed |
| CMS, not live | Drafts, unpublished or scheduled entries | Exclude from the inventory; keep them in the CMS |
Google describes a sitemap as the URLs you think are important and want indexed, with canonical URLs only (see Build and submit a sitemap). That makes the “sitemap, but not a 200” rows quick wins: they are wrong by definition.
From four sources to one list
Collect
Sitemaps, crawl, CMS export, Search Console
Normalize
Protocol, host, trailing slash, parameters, redirects
Join on the final URL
One row per canonical URL, with a found-in flag per source
Read the gaps
Each source combination points to a specific fix
Classify and assign
Page type, topic, intent, owner
Step 4: Choose the columns that earn their place
Every column has a maintenance cost. A column nobody fills in makes the whole file look unreliable, so start with the ones below and add more only when a decision needs them.
| Column | Source | Allowed values or format |
|---|---|---|
| Identity | ||
| URL | Reconciled list | Final, normalized URL |
| HTTP status | Crawl | 200, 301, 404 and so on |
| Redirect target | Crawl | Final URL, or empty |
| Indexable | Crawl, Page indexing report | Yes, noindex, canonicalized elsewhere |
| Found in | Reconciliation | Sitemap, crawl, CMS, Search Console |
| Classification | ||
| Page type | Rules, then review | A short fixed list: guide, article, product, category, tool, landing page, legal |
| Topic | Rules, then review | Your topic list, one value per page |
| Search intent | Review against the results page | Informational, commercial, transactional, navigational |
| Language or market | URL pattern or hreflang | Language code |
| Ownership and lifecycle | ||
| Owner | Team | A team or person; “unowned” is allowed |
| Published | CMS | Date |
| Last substantive update | Change log or text comparison | Date the main content last changed meaningfully |
| Review due | Rule by page type | Date |
| Performance | ||
| Clicks and impressions | Search Console | Last 90 days, page level |
| Key events | GA4 | Last 90 days, by landing page |
Three notes on the columns that cause the most trouble:
- Page type and topic need a fixed list. Free text drifts into “blog”, “Blog post” and “article” for the same thing, and the rollups break. Write the allowed values on a second sheet, a data dictionary, and validate against it.
- The CMS “modified” date is not the update date. It changes when a plugin or template touches the record. The date that matters is when the main content last changed meaningfully. See what counts as a real content update.
- Performance columns go stale fastest. Store the date range next to them, and refresh them on a schedule rather than by hand.
Classify with rules first, then review
On a large site, classify in layers. URL patterns and templates settle most rows: everything under /blog/ using the article template is an article. What is left, usually the oldest and oddest pages, needs a person or a model to read the page. Before trusting either, take a random sample of 50 rows, classify them by hand, and compare. If more than a few disagree, fix the rules or the definitions before you scale up.
Worked example
An illustrative example with made-up numbers, not client data: a B2B software site with a blog, a guides section, product pages and a help center on a subdomain.
| Check | Result |
|---|---|
| URLs per source | Sitemaps 1,302 · crawl 1,268 · CMS 1,190 · Search Console pages with impressions 1,104 |
| After normalizing and joining | 1,421 unique final URLs |
| Sitemap URLs that redirect or return 404 | 41, all from a section merged last year |
| In the sitemap, not reached by the crawl | 37 pages; nine of them still earn clicks |
| Reached by the crawl, not in the CMS | 64 landing pages built in a separate page builder, owned by paid media |
| In Search Console only | 48 old URLs, mostly with tracking parameters; folded into their canonical rows |
| Owner column | 212 rows “unowned”, most of them blog articles by people who have left |
None of these findings needed a quality score. They came from comparing lists. The first week’s work writes itself: fix the sitemap, link or retire the 37 unreached pages, bring the 64 landing pages into scope with an owner, and assign the 212 unowned rows by topic.
Step 5: Keep it current without rebuilding it
Most inventories are accurate for about a month. The fix is not a bigger yearly rebuild; it is a small number of triggers that update the rows when something changes, plus a scheduled comparison that catches what the triggers missed.
| Trigger | Update in the inventory | Who |
|---|---|---|
| A page is published | New row with type, topic, owner, review date | The publisher, as part of the publishing checklist |
| A page is merged, moved or retired | Status and redirect target; the row stays | Whoever changes the redirect |
| A substantive update | Last substantive update and next review date | The editor |
| An owner leaves or a team changes | Owner on every affected row | The content lead |
| Monthly | Re-pull the four sources and review only the differences | The inventory owner |
| Quarterly | Refresh performance columns and review dates | The inventory owner |
The monthly comparison is the part that makes the inventory trustworthy. Do not rebuild the list; compare this month’s sources with last month’s and look only at what changed: new URLs, URLs that disappeared, status changes, and pages whose main content changed. On a site of a few thousand pages that is usually a short list, and it is the list that matters.
Give the inventory one named owner. A shared file that everyone can edit and nobody is responsible for drifts within a quarter.
Common mistakes
- Starting from the CMS alone. It misses everything published outside it, which is often where the problems are.
- Joining before normalizing. The same page with and without a trailing slash becomes two rows with half the traffic each.
- Deleting redirect rows. They are the memory you need for the next merge or migration.
- Too many columns. Twenty fields nobody maintains are worse than ten that everyone trusts.
- Treating it as a project. An inventory is a living file with triggers and an owner, not a deliverable.
Where Morrison fits
You can build all of this with a crawler, a CMS export, Search Console and a spreadsheet. Morrison does the collecting and classifying part for you and keeps it current. Its site index reads your sitemap first, shows the sections and languages it found with page counts, and crawls the sections you choose. Every URL gets its HTTP status and redirect target, metadata, a page type and a search intent, and 90 days of Search Console clicks and impressions, in a grid you can filter, group into segments and export to CSV. It also knows when each page’s text last changed. New pages are picked up daily and every page is recrawled on your plan’s schedule.
It reads main content only, so it does not see navigation or footer links, click depth or page speed; use a technical crawler for those. Owner and review dates still come from your team. See content audit and inventory.
Key takeaways
- One row per canonical URL, with the edge cases decided and written down before you collect.
- Four sources, reconciled: sitemaps, a crawl, the CMS and Search Console. The gaps between them are findings.
- Few columns, fixed values: identity, classification, ownership and performance, with a data dictionary.
- Triggers, not rebuilds: update rows when pages change, and compare sources monthly.
- One owner: an inventory nobody owns is out of date within a quarter.
Sources and further reading
- 1What is a sitemapGoogle Search Central
- 2Build and submit a sitemapGoogle Search Central
- 3Page indexing reportSearch Console Help
- 4Performance report (Search results)Search Console Help
- 5A deep dive into Search Console performance data filtering and limitsGoogle Search Central Blog
- 6Bulk data export: a new and powerful way to access your Search Console dataGoogle Search Central Blog
- 7How to specify a canonical URL with rel="canonical" and other methodsGoogle Search Central
- 8Redirects and Google SearchGoogle Search Central
- 9Link best practices for GoogleGoogle Search Central

CEO, Morrison
Ulrich is CEO of Morrison and founded Bonzer in 2017, growing it into one of Scandinavia's leading SEO agencies with 900+ clients across Copenhagen, Oslo, and Stockholm. At Morrison he leads strategy, operations and go-to-market, bringing years of hands-on SEO and content work to the platform side of the business.