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.

Ulrich Svarrer

Ulrich Svarrer

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.

FileAnswersChanges when
InventoryWhat pages exist, what each one is and who owns itA page is published, moved, merged or retired
AuditHow good each page is and what to do with itYou run an audit, usually once or twice a year
Keyword mapWhich page should rank for which searchesYou 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 caseDefaultWhy
Redirecting URLsKeep as rows, with the target, marked as redirectsYou need them for redirect hygiene and the next migration
Parameter and tracking URLsExclude; fold into the canonical rowThey are the same page and would double-count
Language and market versionsOne row each, with a column for the languageEach has its own owner, date and performance
Paginated archive pagesExclude, except page oneTheir content is generated from other rows
Tag and author archivesInclude, marked as archivesThey are often thin and indexable, worth a decision
PDFs and other filesInclude if they get search trafficGoogle can index them, and they go stale quietly
Subdomains (help center, docs)Include if a content team owns themOwnership decides scope, not the hostname
noindex pagesInclude, marked noindexAccidental 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.

SourceHow to pull itWatch out for
XML sitemapsStart at the sitemap index (often listed in robots.txt) and expand every child sitemapA sitemap file holds at most 50,000 URLs, so large sites split them; plugins often omit page builders and file types
A crawlA crawler starting from the homepage, following linksOnly finds pages that something links to; misses pages blocked by robots.txt or behind logins
CMS exportA database or API export of published entries, with author, type and datesStores slugs, not live URLs; its “modified” date changes on template and plugin updates
Search ConsoleThe Pages table of the Performance report, plus the Page indexing reportThe 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 inUsually meansNext step
All fourA healthy, known pageNone; this is your baseline
Sitemap, not crawlNothing links to it, or the crawl was blockedCheck it as a possible orphan page
Crawl, not sitemapThe sitemap generator misses a section or page typeFix the generator if the pages should be indexed
Crawl, not CMSPage builder, microsite, legacy static pageFind the owner; decide whether it belongs in scope
Search Console, nowhere elseParameter variants, old URLs, or pages you forgotCheck status; redirect or fold into the canonical row
Sitemap, but not a 200The sitemap lists redirects or errorsRemove them: a sitemap should list the canonical URLs you want indexed
CMS, not liveDrafts, unpublished or scheduled entriesExclude 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.

ColumnSourceAllowed values or format
Identity
URLReconciled listFinal, normalized URL
HTTP statusCrawl200, 301, 404 and so on
Redirect targetCrawlFinal URL, or empty
IndexableCrawl, Page indexing reportYes, noindex, canonicalized elsewhere
Found inReconciliationSitemap, crawl, CMS, Search Console
Classification
Page typeRules, then reviewA short fixed list: guide, article, product, category, tool, landing page, legal
TopicRules, then reviewYour topic list, one value per page
Search intentReview against the results pageInformational, commercial, transactional, navigational
Language or marketURL pattern or hreflangLanguage code
Ownership and lifecycle
OwnerTeamA team or person; “unowned” is allowed
PublishedCMSDate
Last substantive updateChange log or text comparisonDate the main content last changed meaningfully
Review dueRule by page typeDate
Performance
Clicks and impressionsSearch ConsoleLast 90 days, page level
Key eventsGA4Last 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.

CheckResult
URLs per sourceSitemaps 1,302 · crawl 1,268 · CMS 1,190 · Search Console pages with impressions 1,104
After normalizing and joining1,421 unique final URLs
Sitemap URLs that redirect or return 40441, all from a section merged last year
In the sitemap, not reached by the crawl37 pages; nine of them still earn clicks
Reached by the crawl, not in the CMS64 landing pages built in a separate page builder, owned by paid media
In Search Console only48 old URLs, mostly with tracking parameters; folded into their canonical rows
Owner column212 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.

Morrison searches its site index for blog URLs that redirect or fail, checks them against the sitemap and writes the list to a file.

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.

TriggerUpdate in the inventoryWho
A page is publishedNew row with type, topic, owner, review dateThe publisher, as part of the publishing checklist
A page is merged, moved or retiredStatus and redirect target; the row staysWhoever changes the redirect
A substantive updateLast substantive update and next review dateThe editor
An owner leaves or a team changesOwner on every affected rowThe content lead
MonthlyRe-pull the four sources and review only the differencesThe inventory owner
QuarterlyRefresh performance columns and review datesThe 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

  1. 1What is a sitemapGoogle Search Central
  2. 2Build and submit a sitemapGoogle Search Central
  3. 3Page indexing reportSearch Console Help
  4. 4Performance report (Search results)Search Console Help
  5. 5A deep dive into Search Console performance data filtering and limitsGoogle Search Central Blog
  6. 6Bulk data export: a new and powerful way to access your Search Console dataGoogle Search Central Blog
  7. 7How to specify a canonical URL with rel="canonical" and other methodsGoogle Search Central
  8. 8Redirects and Google SearchGoogle Search Central
  9. 9Link best practices for GoogleGoogle Search Central
Ulrich Svarrer

Ulrich Svarrer

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.

See Morrison on your own site.

Start a 14-day free trial and index your site in minutes. Or book a demo and we crawl it before the call.

Compare plans