iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🍞

Centralizing Navigation, Breadcrumbs, and SEO Metadata with a Type-Safe Structure Map

に公開

Introduction: The "Metadata Clutter" Problem in the Frontend

Metadata management is often sidelined in frontend design. If managed haphazardly, you will inevitably face the following "hell" midway through a project:

  • Fear of global find-and-replace: Searching the entire project just to change /about/ to /company/.
  • Breadcrumb collapse: Fixing logic every time the hierarchy changes.
  • SEO inconsistencies: Mismatches between page titles and JSON-LD.

There is only one cause: The Single Source of Truth (the "correct" information) is fragmented.

In this article, I will introduce a design to centralize the management of URLs, meta tags, navigation, breadcrumbs, and JSON-LD using a single "Sitemap object", leveraging TypeScript's type inference.

Design Philosophy: Automatically Generating Everything from the "Entity"

As mentioned earlier, the cause of this "hell" is one thing: The Single Source of Truth is fragmented.

In this article, the "entity" is consolidated into a single object called Sitemap, and UI (navigation, breadcrumbs) and SEO (Meta, JSON-LD) are extracted from it in a type-safe manner.

Sitemap Definition (Fully Entity-Centric)

type SitemapItem = {
  title: string;
  description: string;
  link: string;
  parent?: PageKey;
};

export const Sitemap = {
  home: {
    title: 'Home',
    description: 'Top page',
    link: '/',
  },
  about: {
    title: 'About Us',
    description: 'Company overview page',
    link: '/about/',
    parent: 'home',
  },
  service_web: {
    title: 'Web Development Service',
    description: 'Details of Web development',
    link: '/services/web/',
    parent: 'about',
  },
} as const satisfies Record<string, SitemapItem>;

// Check types like this
export type PageKey = keyof typeof Sitemap;

TypeScript will immediately error out on values that do not satisfy parent: PageKey.

In other words:

  • URL changes
  • Page deletions
  • Key typos

These will be detected as build-time errors rather than "runtime bugs."

This is the primary value of a "design protected by types."

The Build Stops the Moment the Type Breaks

For example, if you write a non-existent parent key:

about: {
  parent: 'hom' // typo
}

How to Represent Parent-Child Relationships

In the code shown above, the parent-child relationship is created by specifying a parent for each item, but another approach is to have the parent element hold children.

There are trade-offs for both methods. You should choose based on the characteristics of your project.

Cases where the parent method is suitable

  • Frequent changes to the site structure
  • A need to dynamically generate multiple different navigations (GNAV, sidebars, footers)
  • A large number of pages and deep hierarchies

Cases where the children method is suitable

  • Relatively stable site structure
  • Navigation generation performance is the top priority
  • Team members are comfortable with tree structures

In this article, we adopt the parent method, prioritizing flexibility for structural changes (this approach is also widely used in database design).

Comparison Item children method parent method
Ease of structural changes △ Requires restructuring nests ◎ Only change the parent key
Nav generation efficiency ◎ Immediate access △ Requires filtering every time
Multiple UI generation ○ Can be handled with conversion functions ◎ Can be generated more flexibly
Prevention of circular references ◎ Structurally impossible △ Runtime check required
Intuitiveness of implementation ◎ Tree structure as is △ Requires pointer-like thinking
Suitable projects Stable structure, small/medium scale Frequent structure changes, large scale

"Generate the UI from the data relationships, rather than fitting the data to the UI shape (nesting)." This is the core of this design.

children method (Hierarchical/Tightly Coupled)

This is a format where data is nested exactly as it appears in the UI. Moving a single branch requires physically relocating all of its child elements, turning management into a "tree restructuring" task.

parent method (Flat/Loosely Coupled)

This is a format where all pages are listed on the same level, and each page only holds a pointer (reference) to "who its parent is." Moving a page is as simple as updating the parent property, ensuring the data structure remains intact.

How to automatically generate GNAV based on parent

You don't need to manually write URLs or titles for the navigation.

type NavItem = SitemapItem & {
  key: PageKey;
  children: NavItem[]; // Initialize with an empty array even if there are no children
};

const generateNav = (sitemap: typeof Sitemap): NavItem[] => {
  const roots: NavItem[] = [];
  const itemMap = new Map<PageKey, NavItem>();

  // ① Register all pages at once (O(N))
  (Object.keys(sitemap) as PageKey[]).forEach((key) => {
    itemMap.set(key, {
      ...sitemap[key],
      key,
      children: [],
    });
  });

  // ② Connect parent-child relationships (O(N))
  itemMap.forEach((item, key) => {
    const parentKey = sitemap[key].parent;

    if (!parentKey) {
      roots.push(item);
      return;
    }

    const parent = itemMap.get(parentKey);

    // Assume type safety, but verify during development only
    if (!parent) {
      if (process.env.NODE_ENV !== 'production') {
        throw new Error(`Invalid parent key: ${parentKey}`);
      }
      roots.push(item);
      return;
    }

    parent.children.push(item);
  });

  return roots;
};

export const GNAV = generateNav(Sitemap);

Rendering Example in React

import { GNAV } from '@/lib/sitemap';

export function Header() {
  return (
    <nav aria-label="Main Navigation">
      <ul className="nav-list">
        {GNAV.map((item) => (
          <li key={item.key} className="nav-item">
            <a href={item.link} className="nav-link">
              {item.title}
            </a>

            {item.children.length > 0 && (
              <ul className="nav-sub-list">
                {item.children.map((child) => (
                  <li key={child.key}>
                    <a href={child.link}>{child.title}</a>
                  </li>
                ))}
              </ul>
            )}
          </li>
        ))}
      </ul>
    </nav>
  );
}

Rendering Example in Astro

---
import { GNAV } from '@/lib/sitemap';
---

<nav>
  <ul class="gnav">
    {GNAV.map((item) => (
      <li class="nav-item">
        <a href={item.link}>{item.title}</a>
        
        {/* Output <ul> only if child elements exist */}
        {item.children.length > 0 && (
          <ul class="nav-sub">
            {item.children.map((child) => (
              <li>
                <a href={child.link}>{child.title}</a>
              </li>
            ))}
          </ul>
        )}
      </li>
    ))}
  </ul>
</nav>

The essence remains the same whether using React or Astro. Only the rendering portion is different.

Since we have the parent property, breadcrumbs are created simply by tracing back to the parents from the current location.

export const getBreadcrumbs = (currentKey: PageKey) => {
  const items: { title: string; link: string }[] = [];
  let key: PageKey | undefined = currentKey;

  while (key) {
    const page = Sitemap[key];
    if (!page) {
      console.error(`Sitemap does not contain the page for key: ${key}`);
      break; // Exit loop if the key does not exist in the Sitemap
    }
    items.unshift({ title: page.title, link: page.link });
    key = page.parent; // Move to the parent key
  }
  return items;
};

Practical Considerations: Preventing Circular References

The parent method is very simple, but if you accidentally create a circular reference, it will lead to an infinite loop.

A → B
B → C
C → A

In a real-world scenario, it is safer to record visited keys just in case.

export const getBreadcrumbs = (currentKey: PageKey) => {
  const items: { title: string; link: string }[] = [];
  const visited = new Set<PageKey>();
  let key: PageKey | undefined = currentKey;

  while (key && !visited.has(key)) {
    visited.add(key);

    const page = Sitemap[key];
    items.unshift({ title: page.title, link: page.link });

    key = page.parent;
  }

  return items;
};

While the ideal design prevents cycles, adding a single line of defensive code makes it much more robust for practical use.

Note 1: Performance Optimization in React

The generation of GNAV and the calculation of getBreadcrumbs are fast enough unless the Sitemap grows to a scale of hundreds of items. If you are concerned about the calculation cost per render, you can wrap them in useMemo or calculate and export them once at the module scope (outside the component) to effectively reduce the runtime cost to zero.

Note 2: Handling Dynamic Paths (e.g., /posts/[id])

This design is for managing the "permanent structure" of the site. For dynamic paths such as article details, simply define link: '/posts/' (the list page) in the Sitemap and refer to that key as the parent on the detail page side. There is no need to include every "individual article" in this object; the goal of this design is to make the structure type-safe up to its endpoints (containers).

// Example: Inside a blog detail component
const baseBreadcrumbs = getBreadcrumbs('blog_list');
const fullBreadcrumbs = [
  ...baseBreadcrumbs,
  { title: post.title, link: Astro.url.pathname } // Add dynamic data at the end
];

Expanding to SEO and JSON-LD

The obtained breadcrumb array can be directly converted into a BreadcrumbList schema.

export const getBreadcrumbSchema = (currentKey: PageKey, siteUrl: string) => {
  const items = getBreadcrumbs(currentKey);
  return {
    '@context': 'https://schema.org',
    '@type': 'BreadcrumbList',
    itemListElement: items.map((item, index) => ({
      '@type': 'ListItem',
      position: index + 1,
      name: item.title,
      item: new URL(item.link, siteUrl).href,
    })),
  };
};

Usage in Components (Astro / React)

When linking to individual pages, refer to the Sitemap object instead of writing the URL directly. This ensures that when a URL changes, you only need to fix the Sitemap in one place to synchronize all links and SEO information.

---
import { Sitemap } from '@/lib/sitemap';
---
<a href={Sitemap.about.link}>{Sitemap.about.title}</a>

Implementation via the parent method

This diagram shows the flow of tracing from service_web to its parent about, and then to its parent home. Conversely, the advantage of the parent method is that you can automatically reverse-lookup the hierarchy from a specific URL simply by tracing the parents.

Automatic Application of Meta Tags

By defining SitemapByPath, we can search the contents of the Sitemap based on the URL path. This allows you to easily retrieve page information corresponding to a specific path (e.g., /about/).

// Create an index using Sitemap links as keys
export const SitemapByPath = Object.fromEntries(
  Object.entries(Sitemap).map(([key, page]) => [
    page.link.replace(/\/$/, '') || '/', // Normalize the link on the definition side to use as a key
    { key, ...page },
  ])
) as Record<string, { key: PageKey; title: string; description: string; link: string; parent?: PageKey }>;

Using SitemapByPath, you can easily retrieve page information as follows:

---
// Layout.astro
import { SitemapByPath } from '@/lib/sitemap';

// Normalize the current path (remove trailing slash, but keep the root "/")
const currentPath = Astro.url.pathname.replace(/\/$/, '') || '/';
const currentPage = SitemapByPath[currentPath];
---
<title>{currentPage?.title ?? "Default Title"}</title>
<meta
  name="description"
  content={currentPage?.description ?? "Default description"}
/>

By performing a reverse lookup from the pathname, you can automatically set SEO information in the Layout.

The Difference Between the children and parent Methods is Not the "Search Direction"

Both the children method and the parent method actually require search processing.

  • children method → Recursive search is needed to find the parent
  • parent method → Filtering from a list is needed to find children

Looking only at this point, the two are symmetrical. So, where does the difference lie?

Differences in Design Philosophy

Characteristics of the children method:

  • Directly represents the tree structure as data.
  • Tends to design data based on the "most common display structure."
  • Handles different UI requirements with conversion functions.

Characteristics of the parent method:

  • Holds only the relationships (parent-child) between pages.
  • Display structures are dynamically generated according to the purpose.
  • High degree of separation between data and display.

While both allow for "data-centric design," the parent method can be said to have a lower dependency on the display format.

Demo (CodeSandbox)

You can check simple operations for both React and Astro.

React

https://codesandbox.io/p/sandbox/sharp-zhukovsky-24nl8j

Astro

https://codesandbox.io/p/devbox/gallant-tristan-ggy4h5

[Advanced] Usage with Astro 5.0+ Content Layer

Since Astro 5.0, the "Content Layer"—which allows for type-safe handling of external data and local files—has become standard. By integrating this Sitemap definition into src/content/config.ts, you can reap even more powerful benefits.

// src/content/config.ts
const sitemap = defineCollection({
  loader: file("src/data/sitemap.json"), // Load from JSON or an external API
  schema: z.object({
    title: z.string(),
    description: z.string(),
    link: z.string(),
    parent: z.string().optional(), // This corresponds to PageKey
  })
});

Why is it so compatible with Content Layer?

  • Automatic validation: With the zod schema, you can enforce the link format and mandatory fields at the time of data entry.
  • Build performance: Even with a large-scale sitemap, Astro optimizes (caches) it internally, minimizing runtime overhead.
  • Automatic type generation: By using getCollection('sitemap'), Astro keeps the types up-to-date based on the latest data without you needing to manually write as const satisfies.

Summary: Maintainability Starts with Data Normalization

UIs change. Designs change. Navigation structures will also change in the future.

However, only the "relationships" between pages are essential information.

Storing relationships rather than structures—that is the heart of this design.

  • Entity (Sitemap): Only in one place.
  • Display (Navigation, Breadcrumbs, SEO): Automatically generated from the entity.

By establishing this relationship, elementary mistakes like "outdated breadcrumbs despite changed link destinations" or "404 errors due to missed navigation updates" are structurally unlikely to occur.

The larger the project, the greater the benefits of this "data-centric design." Consider it as the backbone of your next project.

Discussion