# A smaller catalogue page without losing crawlable navigation

> Source: <https://dev.to/ar_karakaya_b6b310823e/a-smaller-catalogue-page-without-losing-crawlable-navigation-4c3g>
> Published: 2026-10-27 14:40:04+00:00

Disclosure: This article was primarily drafted by an AI assistant using official documentation and a reviewed technical example. The code is a generic teaching example, not production application code. It does not report a measured performance or search-ranking improvement.

A catalogue page has at least two different jobs. It lets people browse a stable page of linked items, and it may let them search a much larger collection. A visitor reading one page does not necessarily need the entire search index in the initial HTML or hydration data.

One way to separate those jobs is to return the current page’s items and total count from the server, then request full public metadata in the browser only when somebody starts a search. Normal browsing can use links such as /en/catalogue?page=2, so navigating the collection does not depend on a JavaScript-only button.

A page size such as 60 items is an implementation choice, not a Google rule or a universal performance threshold. Choose a page size based on the information people need and measure the resulting document.

Before introducing search, make the basic browse path work. Each item should have a real destination link. Each page should have a real URL and a sensible title. Previous/next controls should use anchors. A search interaction can be richer, but it should not be the only way to discover the catalogue.

Google’s pagination guidance describes how crawlers find content through links and recommends distinct URLs for pages in a sequence. It also explains why button-driven incremental loading needs attention. This is discoverability guidance, not a promise that every linked item will be indexed.

For example:

```
<nav aria-label="Catalogue pages">
  <a href="https://dev.to/en/catalogue">Previous</a>
  <a href="https://dev.to/en/catalogue?page=2" aria-current="page">2</a>
  <a href="https://dev.to/en/catalogue?page=3">Next</a>
</nav>
```

Treat each actual browse page consistently in canonical and language metadata. Do not canonicalize every distinct page to page one simply because it belongs to the same catalogue.

The following loader illustrates the client boundary. It expects an example same-origin endpoint, /demo/catalog.json?lang=en, returning items with id and name, a boolean partial field, and optionally unavailable=true. This endpoint is illustrative; you would need to implement it for your application.

It includes no credentials and must only be used for public metadata. Do not reuse this shared cache for wallets, messages, orders or identity. A shared Promise prevents multiple consumers in the same loader instance from starting identical reads simultaneously. It is not cross-browser, cross-worker or globally distributed deduplication.

```
// Educational browser loader. /demo/catalog.json is an example endpoint.
// Public metadata only; never cache account data here.
export function createCatalogLoader(fetcher = fetch, now = Date.now) {
  const locales = new Set(['en', 'tr', 'pt', 'de', 'ru', 'id', 'bn', 'es']);
  const cache = new Map();
  const pending = new Map();

  return async function loadCatalog(locale) {
    if (!locales.has(locale)) throw new Error('UNSUPPORTED_LOCALE');
    const hit = cache.get(locale);
    if (hit && now() - hit.at < (hit.value.partial ? 15_000 : 300_000)) {
      return hit.value;
    }
    if (pending.has(locale)) return pending.get(locale);

    const work = (async () => {
      const response = await fetcher(`/demo/catalog.json?lang=${locale}`, {
        credentials: 'omit',
        cache: 'no-store',
        signal: AbortSignal.timeout(8_000),
      });
      if (!response.ok) throw new Error('CATALOG_UNAVAILABLE');
      const raw = await response.json();
      if (!raw || raw.unavailable === true || typeof raw.partial !== 'boolean'
        || !Array.isArray(raw.items) || raw.items.length > 10_000
        || !raw.items.every(row => row && typeof row.id === 'string'
          && typeof row.name === 'string')) {
        throw new Error('CATALOG_UNAVAILABLE');
      }
      const value = Object.freeze({
        partial: raw.partial,
        items: Object.freeze(raw.items.map(row => Object.freeze({id: row.id, name: row.name}))),
      });
      cache.set(locale, {at: now(), value});
      return value;
    })();
    pending.set(locale, work);
    try { return await work; }
    finally { pending.delete(locale); }
  };
}
```

There are deliberate limits. The metadata endpoint still needs its own server-side controls and appropriate size limits. The example validates the fields it consumes, not every possible input. AbortSignal.timeout needs a compatible runtime. Its timer measures active time and is not a universal wall-clock SLA. Adapt browser compatibility handling to your audience.

An unavailable request and a successful empty result mean different things. Rendering “no matches” after a timeout tells a user something the interface does not know.

Keep these states distinct:

| State | Useful user-facing behavior | 
|---|---|
| Browsing | Keep the server-rendered page and its links usable. | 
| Loading search | Announce loading through a status region. | 
| Complete search | Show matches; an empty message is now meaningful. | 
| Partial search | Show available matches with an explicit incomplete-data notice. | 
| Unavailable search | Explain that search could not load and offer retry. | 

A partial response with zero records must not silently become a complete empty result. That is why the partial flag remains in the return value instead of being discarded after parsing.

In React, also guard the context in which a response is displayed. If somebody changes language while the previous language’s request is still running, its result must not overwrite the current screen. An Effect cleanup flag can ignore a late result for a replaced context. React’s own documentation demonstrates this kind of race-condition guard. Cancellation and ignoring obsolete results solve related but different problems; a shared request may still have another active reader.

First compare decoded HTML size and the number of requests before anyone touches search. Then start search and check whether one public read is reused. Test a delayed response, a failed response followed by retry, a partial empty response and a language change during loading. Finally, clear search and verify that the original page is useful again.

Use a browser performance tool for LCP, INP and CLS. A smaller HTML payload is not, by itself, a measured Core Web Vitals improvement. Neither a successful build nor an indexable URL establishes search ranking gains or a better user experience.

To test the loader without external services, inject a mocked fetcher and clock. Check concurrent reads, cache expiry, unsupported locale rejection, retries after failure and the shorter lifetime of partial data. Keep fixture results separate from claims about real network behavior.

For a modest public metadata catalogue, a first-use full index can be reasonable. If it becomes large enough that loading it on the first query is expensive, switch the optional search path to a bounded server search endpoint instead. Keep browse URLs usable in either case.

The useful outcome is simple: initial HTML serves the visitor’s present task, optional JavaScript serves the next task, and an error message describes what the system knows.

Sources:
