How to Make Your Own New York Times–Style HTML Sitemap for SEO — With WordPress Code

Illustration of a WordPress HTML sitemap connecting topic hubs to groups of articles

You may have seen the New York Times sitemap featured online. SEOs like to mention it a lot, especially on LinkedIn.

The reality is more useful than the mythology. An XML sitemap gives search engines a machine-readable list of URLs. An HTML sitemap gives people and crawlers a page of ordinary links they can browse. Those jobs overlap, but they are not interchangeable.

The useful lesson from The New York Times is its indexing principle, not its visual design. A large body of content becomes easier to explore when it is arranged into clear routes, with useful labels and progressively narrower groups. You can apply the same principle to a WordPress publication, course library or knowledge base without reproducing the Times’ design or using any of its code.

This tutorial builds a server-rendered Resource Index from the categories or taxonomy terms you already use. It produces crawlable links, updates when content changes and inherits your theme’s design tokens. It also avoids a surprisingly common mistake: restructuring URLs just to make the index look tidy.

What the New York Times index gets right

At the time of research, the search-accessible version of the New York Times Site Map exposed quick routes for recent periods and a year index running back to 1851. A year page then narrowed the archive by month. Direct automated access to the live page was blocked, so this is an observation from the page’s indexed representation rather than a claim about its private implementation.

That hierarchy suits a newspaper with more than a century of date-led material. Your site probably needs different groups: services, subjects, content types, audiences or learning stages. The transferable idea is progressive disclosure. Start with recognisable hubs, then let the reader move to a useful set of resources.

An HTML sitemap is a published web page containing links to selected pages on the same site. A good one is closer to a library catalogue than a database dump. It uses headings, descriptions and link labels that help someone decide where to go next.

Why this can help search and readers

Search engines discover pages by following links. Google says every page you care about should have a link from at least one other page, and that normal <a> elements with an href are the reliably crawlable form. Descriptive anchor text also helps people and Google understand the destination (Google Search Central: link best practices).

That makes a well-linked index useful in several ways. It can shorten the route from the home page to older resources, provide internal links to pages that would otherwise be orphaned, make topic relationships explicit and give visitors a browseable alternative to site search. Linking the index from the footer makes the route consistently available without crowding the main menu.

“Crawl depth” is simply the number of link steps needed to reach a URL from a starting page, usually the home page. A footer link to the index, followed by a topic link or article link, can put important material within a few steps. Google’s long-standing guidance is to keep important pages within several clicks of the home page and to use an intuitive, crawlable link structure (Google Search Central: link architecture). Treat that as a design goal, not a promise that reducing a crawler’s reported depth from four to three will improve rankings.

An index is also not a substitute for contextual links. A relevant link from an article or course page tells the reader why another resource matters at that moment. An index supplies broad discovery; related reading supplies local context.

HTML and XML sitemaps have different jobs

Keep your XML sitemap. WordPress core or your SEO plugin will normally generate it, and you can submit it through Search Console. Google describes a sitemap as a file that identifies important URLs and their relationships. It may improve discovery on larger or more complex sites, but inclusion does not guarantee crawling or indexing (Google Search Central: sitemaps).

The HTML index belongs in the visible site architecture. It is useful to readers, passes through ordinary crawlable links and can add topic context. The XML sitemap supplies structured URL discovery to search engines. Run both when both solve a real need.

Plan the information architecture before writing code

Start with the organisation already represented in WordPress. For a typical editorial site, categories are the strongest first choice because they usually describe broad subjects and already have archive URLs. A specialist site might use a custom taxonomy such as resource_topic, course_subject or audience.

Each top-level index section should answer a reader question. “Analytics”, “WordPress” and “Paid media” are clearer than internal department names. The linked term archive becomes a topic hub, while the individual resources beneath it offer direct routes to content. Add a short term description in WordPress when the label alone does not explain the scope.

Do not create dozens of near-empty categories for the index. Merge overlapping labels at the presentation layer where appropriate, or curate the most useful sections. A flat page containing every URL may technically add links while giving people little help. Google explicitly says there is no magic ideal number of links on a page; usefulness is the better constraint.

Building an index does not require recategorising posts or changing their slugs. Reuse the existing taxonomy and let the template organise its output. This preserves known URLs, external links, analytics continuity and editorial workflows.

A display-name or metadata change does not change a URL when the taxonomy slug and post permalink remain unchanged. For example, changing a category’s visible name from “PPC” to “Paid media” is URL-neutral if its slug stays ppc. Changing the category slug from ppc to paid-media, or changing a post permalink, creates a new URL.

If a URL genuinely must change, map the old URL to the closest equivalent new URL with a permanent server-side 301 or 308 redirect, update your internal links and test both ends. Google recommends permanent server-side redirects when a page has permanently moved (Google Search Central: redirects). Do not redirect a batch of unrelated old URLs to the home page.

A practical implementation pattern

We used this pattern for a training business with an expanding article and course library. Its anonymised implementation has a dynamic Resource Index linked from the footer, stronger category/topic hubs, and six context-aware related-reading cards on course pages. The index gives broad coverage; the six cards connect each course to useful articles in context.

No post permalink or taxonomy slug had to change. Editorial display names and descriptions could be improved independently because URL identity remained tied to the unchanged slugs and permalinks.

For another site, six cards may be too many or too few. Choose the smallest set that stays relevant and useful at the point of reading.

Build the WordPress Resource Index

The plugin below registers a [resource_index] shortcode. It validates the requested post type and taxonomy, includes published non-password-protected posts only, assigns each post to one deterministic topic to prevent duplicate listings, and caches the query data with the WordPress Transients API. It caches data rather than finished markup so unique heading IDs remain valid when the shortcode appears more than once on a page.

You can install this in either of two ways. The most portable option is a small plugin: create a folder named ka-resource-index inside wp-content/plugins/, then create ka-resource-index.php inside it and paste in the complete code below.

You can also add it as one PHP snippet with a snippets manager such as the Code Snippets plugin. In that case, give the snippet a clear name, omit the opening <?php line and plugin header, paste everything from defined( 'ABSPATH' ) || exit; onwards, and set it to run on the public site or everywhere. Use one installation method. The code includes an early guard to prevent a fatal function-redeclaration error if a second copy is accidentally loaded, but running duplicate copies is still confusing and may leave you editing an inactive version.

<?php
/**
 * Plugin Name: Resource Index
 * Description: Adds a dynamic, accessible [resource_index] shortcode.
 * Version:     1.0.0
 * Requires PHP: 7.4
 * License:     GPL-2.0-or-later
 */

defined( 'ABSPATH' ) || exit;

// Stop safely if another plugin or snippet has already loaded this code.
if ( function_exists( 'ka_resource_index_shortcode' ) ) {
	return;
}

/*
 * The `ka_` prefix is intentional. WordPress loads plugins into a shared global
 * namespace, so a short, distinctive prefix reduces the risk of function,
 * option, transient and hook-name conflicts. If `ka_` is already used on your
 * site, search and replace it throughout this file with your own unique prefix.
 */

/**
 * Render the resource index.
 *
 * Usage:
 * [resource_index]
 * [resource_index taxonomy="resource_topic" post_type="resource" max_per_topic="0"]
 *
 * Set max_per_topic to 0 for no per-topic limit. On a very large site,
 * prefer curated or paginated indexes rather than one enormous page.
 */
function ka_resource_index_shortcode( $atts ): string {
	$atts = shortcode_atts(
		array(
			'taxonomy'      => 'category',
			'post_type'     => 'post',
			'max_per_topic' => '50',
		),
		$atts,
		'resource_index'
	);

	$taxonomy      = sanitize_key( $atts['taxonomy'] );
	$post_type     = sanitize_key( $atts['post_type'] );
	$max_per_topic = absint( $atts['max_per_topic'] );
	$post_type_obj = get_post_type_object( $post_type );

	// Reject private post types, invalid taxonomies and unrelated combinations.
	if (
		! $post_type_obj ||
		! $post_type_obj->public ||
		! taxonomy_exists( $taxonomy ) ||
		! is_object_in_taxonomy( $post_type, $taxonomy )
	) {
		return '';
	}

	$version   = (int) get_option( 'ka_resource_index_version', 1 );
	$cache_key = 'ka_ri_' . md5(
		wp_json_encode( array( $taxonomy, $post_type, $max_per_topic, $version ) )
	);
	$groups    = get_transient( $cache_key );

	if ( false === $groups ) {
		$terms = get_terms(
			array(
				'taxonomy'   => $taxonomy,
				'hide_empty' => true,
				'orderby'    => 'name',
				'order'      => 'ASC',
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return '';
		}

		$term_map = array();
		$groups   = array();

		foreach ( $terms as $term ) {
			$term_map[ (int) $term->term_id ] = count( $groups );
			$groups[] = array(
				'id'          => (int) $term->term_id,
				'name'        => $term->name,
				'slug'        => $term->slug,
				'url'         => get_term_link( $term ),
				'description' => term_description( $term, $taxonomy ),
				'posts'       => array(),
			);
		}

		$query = new WP_Query(
			array(
				'post_type'              => $post_type,
				'post_status'            => 'publish',
				'has_password'           => false,
				'posts_per_page'         => -1,
				'orderby'                => 'title',
				'order'                  => 'ASC',
				'no_found_rows'          => true,
				'ignore_sticky_posts'    => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => true,
			)
		);

		$seen = array();

		foreach ( $query->posts as $post ) {
			$post_id = (int) $post->ID;

			if ( isset( $seen[ $post_id ] ) ) {
				continue;
			}

			// Let another plugin exclude noindex or otherwise unsuitable content.
			if ( ! apply_filters( 'ka_resource_index_include_post', true, $post_id ) ) {
				continue;
			}

			$post_terms = get_the_terms( $post_id, $taxonomy );
			if ( is_wp_error( $post_terms ) || empty( $post_terms ) ) {
				continue;
			}

			// Match the same alphabetical order used for the topic sections.
			usort(
				$post_terms,
				static function ( WP_Term $a, WP_Term $b ): int {
					return strcasecmp( $a->name, $b->name );
				}
			);

			$default_term_id = (int) $post_terms[0]->term_id;
			$term_id = (int) apply_filters(
				'ka_resource_index_term_id',
				$default_term_id,
				$post_id,
				$post_terms
			);

			if ( ! isset( $term_map[ $term_id ] ) ) {
				continue;
			}

			$group_index = $term_map[ $term_id ];
			if (
				$max_per_topic > 0 &&
				count( $groups[ $group_index ]['posts'] ) >= $max_per_topic
			) {
				continue;
			}

			$groups[ $group_index ]['posts'][] = array(
				'id'    => $post_id,
				'title' => get_the_title( $post_id ),
				'url'   => get_permalink( $post_id ),
			);
			$seen[ $post_id ] = true;
		}

		wp_reset_postdata();

		// Remove sections left empty by limits, filters or primary-topic assignment.
		$groups = array_values(
			array_filter(
				$groups,
				static function ( array $group ): bool {
					return ! empty( $group['posts'] );
				}
			)
		);

		// Expiration is a maximum; the cache can disappear earlier and regenerate.
		set_transient( $cache_key, $groups, 12 * HOUR_IN_SECONDS );
	}

	if ( empty( $groups ) ) {
		return '<p>' . esc_html__( 'No resources are available yet.', 'ka-resource-index' ) . '</p>';
	}

	$heading_id = wp_unique_id( 'resource-index-title-' );
	$curated    = apply_filters( 'ka_resource_index_curated_links', array() );

	ob_start();
	?>
	<nav class="resource-index" aria-labelledby="<?php echo esc_attr( $heading_id ); ?>">
		<h2 id="<?php echo esc_attr( $heading_id ); ?>">
			<?php esc_html_e( 'Browse all resources', 'ka-resource-index' ); ?>
		</h2>

		<div class="resource-index__grid">
			<?php foreach ( $groups as $group ) : ?>
				<section class="resource-index__section">
					<h3>
						<a href="<?php echo esc_url( $group['url'] ); ?>">
							<?php echo esc_html( $group['name'] ); ?>
						</a>
					</h3>

					<?php if ( $group['description'] ) : ?>
						<div class="resource-index__description">
							<?php echo wp_kses_post( $group['description'] ); ?>
						</div>
					<?php endif; ?>

					<ul class="resource-index__links">
						<?php foreach ( $group['posts'] as $item ) : ?>
							<li>
								<a href="<?php echo esc_url( $item['url'] ); ?>">
									<?php echo esc_html( $item['title'] ); ?>
								</a>
							</li>
						<?php endforeach; ?>

						<?php foreach ( (array) ( $curated[ $group['slug'] ] ?? array() ) as $link ) : ?>
							<?php if ( ! empty( $link['url'] ) && ! empty( $link['label'] ) ) : ?>
								<li class="resource-index__curated">
									<a href="<?php echo esc_url( $link['url'] ); ?>">
										<?php echo esc_html( $link['label'] ); ?>
									</a>
								</li>
							<?php endif; ?>
						<?php endforeach; ?>
					</ul>
				</section>
			<?php endforeach; ?>
		</div>
	</nav>
	<?php

	return (string) ob_get_clean();
}
add_shortcode( 'resource_index', 'ka_resource_index_shortcode' );

/**
 * Incrementing one version invalidates every attribute-specific cache key.
 */
function ka_resource_index_bust_cache(): void {
	$version = (int) get_option( 'ka_resource_index_version', 1 );
	update_option( 'ka_resource_index_version', $version + 1, false );
}

function ka_resource_index_bust_on_save( int $post_id ): void {
	if ( wp_is_post_revision( $post_id ) || wp_is_post_autosave( $post_id ) ) {
		return;
	}

	ka_resource_index_bust_cache();
}

add_action( 'save_post', 'ka_resource_index_bust_on_save' );
add_action( 'deleted_post', 'ka_resource_index_bust_cache' );
add_action( 'created_term', 'ka_resource_index_bust_cache' );
add_action( 'edited_term', 'ka_resource_index_bust_cache' );
add_action( 'delete_term', 'ka_resource_index_bust_cache' );
add_action( 'set_object_terms', 'ka_resource_index_bust_cache' );

If you used the small-plugin method, activate Resource Index under Plugins. If you used a snippets manager, save and activate the single snippet instead. The default shortcode uses posts and categories. For a custom public post type and taxonomy, use attributes such as [resource_index post_type="resource" taxonomy="resource_topic" max_per_topic="100"]. Attribute values are sanitised and checked against registered public WordPress objects rather than passed directly into a query.

The code also offers three filters. ka_resource_index_include_post can exclude a post, including a URL marked noindex by your SEO setup. ka_resource_index_term_id can assign a post to an editorial primary topic instead of the default first alphabetical term. ka_resource_index_curated_links can add selected cornerstone, course or product links to a topic. Keep filter callbacks in a site plugin, test their values and return only trusted internal URLs.

WordPress describes transients as temporary cached data with a maximum expiry, not guaranteed storage. The shortcode therefore regenerates data whenever its transient is unavailable and bumps a shared version after post or term changes (WordPress Transients API). On a site with many thousands of posts, replace the unlimited query with curated post IDs, several post-type indexes or pagination. One huge page is hard to use and expensive to generate even when cached.

Add compact CSS without creating a second design system

Start with the theme’s existing layout, spacing, colour, type and focus variables. The example below provides only layout and state hooks. Replace each fallback with your theme token where one exists; do not create a separate family of cards just for the index.

.resource-index {
  color: var(--color-text, inherit);
}

.resource-index__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
  gap: var(--space-l, 2rem);
  margin-block-start: var(--space-l, 2rem);
}

.resource-index__section {
  padding-block-start: var(--space-s, 1rem);
  border-block-start: 1px solid var(--color-border, currentColor);
}

.resource-index__section h3,
.resource-index__description,
.resource-index__links {
  margin-block-start: 0;
}

.resource-index__links {
  padding-inline-start: 1.25em;
}

.resource-index__links li + li {
  margin-block-start: var(--space-2xs, 0.5rem);
}

.resource-index a:focus-visible {
  outline: 2px solid var(--color-focus, currentColor);
  outline-offset: 0.2em;
}

The heading order is semantic: the page should have one H1 in its normal page template, the index introduces an H2, and each topic uses an H3. The wrapper is a labelled navigation region, lists remain real lists, links work without JavaScript, and keyboard focus remains visible. Do not use buttons or click handlers for navigation.

Publish and verify the page

In WordPress, create a normal Page called Resource Index. Keep the automatically proposed slug if it matches the clean public URL you want, or set the slug once before launch. Add a Shortcode block containing [resource_index], publish the page, and copy its public URL from WordPress rather than guessing it.

Add that verified URL to the site footer with a label such as Resource Index or Browse all resources. Clear the WordPress page cache, host/CDN cache and any object cache used by the site. Open the public URL in a signed-out browser session, confirm it returns 200 OK, view the rendered source and check that article links appear as ordinary <a href="…"> elements. Test a category link, an older article and the footer link in both directions.

You can improve the page later with curated cornerstone resources, courses or products, and concise topic descriptions. Keep commercial links clearly labelled. Add related-reading links or cards to relevant pages as a separate layer; they carry more context than a site-wide directory and should not all point to the same generic set.

Test the outcome, not the existence of the page

A successful launch means important URLs are easier to reach and the index is useful enough for people to use. It does not mean every listed page will be indexed, nor does it guarantee ranking changes.

Use this release and measurement checklist:

  • Confirm the public index and each sampled destination return 200, are canonical to themselves where appropriate, and are not blocked by robots.txt, authentication or a noindex directive.
  • Crawl the site before and after release. Compare the number of indexable pages found, orphan URLs, average and maximum crawl depth, and internal-link counts for the pages you meant to strengthen.
  • Validate HTML landmarks, heading order, keyboard focus, link names, mobile layout and the page with JavaScript disabled.
  • Confirm drafts, private posts, password-protected posts, duplicate listings and irrelevant attachment URLs do not appear. Sample every topic and test cache refresh after publishing, updating, recategorising and deleting content.
  • In Search Console, monitor Page indexing, URL Inspection samples, Crawl stats and internal-link reporting where available. Compare like-for-like periods and allow for normal crawling delays.
  • Measure page views, exits and clicks from the index to topic hubs and resources. On course pages, measure related-reading card use separately so you can tell contextual discovery from index browsing.

Define targets before release. A useful target might be: all priority resources discoverable in the crawl; no priority orphan pages; priority URLs within three link steps of the home page; increased internal-link counts for selected hubs; and index-to-resource clicks that show real visitors use the page. Indexed-page totals and organic clicks are outcome measures, but review quality, canonicalisation and search demand before attributing any change to the index.

Common mistakes are publishing every database URL, linking to redirects or non-canonical variants, listing thin taxonomy archives, hiding links behind JavaScript, changing slugs without a redirect map, leaving cache invalidation untested, or treating the page as a replacement for navigation and contextual links. Another is assuming more links must be better. An index should express structure, not erase it by linking every page to everything else.

A small site may not need a large index. Google says a site of roughly 500 index-worthy pages or fewer may not need a sitemap when all important pages are already well linked, though an XML sitemap is still harmless and commonly generated by the CMS. If your site has 30 pages, a clear header, footer, service navigation and relevant in-copy links may serve readers better than a directory. Build the index when it solves a discovery or usability problem you can name.

FAQ

Does an HTML sitemap improve rankings? Not by itself. It can improve discovery, reduce orphaning and clarify internal relationships, but quality, relevance, canonicalisation and many other factors affect indexing and search performance.

Should I replace my XML sitemap? No. Keep the XML sitemap for structured search-engine discovery. Use the HTML page as visible navigation for people and crawlers.

Will renaming a WordPress category change its URL? Changing only the display name or description will not change the URL if the taxonomy slug stays the same. Editing the slug changes the archive URL and calls for a tested permanent redirect when the old URL has value.

Should every post appear in the index? Not necessarily. Include useful, canonical, indexable content. Curate or split the index when a complete list would be slow or hard to browse.

Can I use Code Snippets or paste the PHP into functions.php? Yes. A PHP snippets manager is a reasonable option: remove the opening <?php line and plugin header, then add the remaining code as one snippet. A small plugin is more portable, and both approaches survive a theme change. Adding it to functions.php can work, but the feature will be tied to that theme. Use only one method, test on staging and keep a rollback copy before activating production code.

Could the snippet crash my site? Valid PHP running once should not. This code has been syntax-checked and includes a guard against loading the same functions twice. A conflict, incomplete paste, unsupported old PHP version or later editing mistake can still trigger an error. Use PHP 7.4 or newer, paste the whole snippet, and activate it on staging first. A snippets manager is safer than editing functions.php directly because reputable managers generally validate code and provide a recovery route or automatically pause a faulty snippet. Before production activation, confirm that you can reach your host’s file manager, SFTP or recovery mode so you can disable the snippet or plugin if needed.

The durable principle is simple: organise content around reader needs, expose those routes through server-rendered links, keep existing URLs stable, and measure whether discovery actually improves.

Want to rank higher and drive more organic traffic?

Technical SEO, content strategy, and performance optimization—we help businesses get found. Let's discuss your SEO goals and create a roadmap.