{"slug": "a-smaller-catalogue-page-without-losing-crawlable-navigation", "title": "A smaller catalogue page without losing crawlable navigation", "summary": "A developer published a teaching example showing how to keep a catalogue page small while preserving crawlable navigation: the server returns only the current page's items and total count, full public metadata is fetched in the browser only when a search begins, and pagination uses real anchor links with distinct URLs rather than JavaScript-only buttons. The example includes a client-side loader with per-locale caching, a shared in-flight Promise to avoid duplicate reads, an 8-second timeout, and strict response validation, and it explicitly notes the code is illustrative, not production application code, and reports no measured performance or ranking improvement.", "body_md": "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.\n\nA 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.\n\nOne 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.\n\nA 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.\n\nBefore 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.\n\nGoogle’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.\n\nFor example:\n\n```\n<nav aria-label=\"Catalogue pages\">\n  <a href=\"https://dev.to/en/catalogue\">Previous</a>\n  <a href=\"https://dev.to/en/catalogue?page=2\" aria-current=\"page\">2</a>\n  <a href=\"https://dev.to/en/catalogue?page=3\">Next</a>\n</nav>\n```\n\nTreat 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.\n\nThe 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.\n\nIt 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.\n\n```\n// Educational browser loader. /demo/catalog.json is an example endpoint.\n// Public metadata only; never cache account data here.\nexport function createCatalogLoader(fetcher = fetch, now = Date.now) {\n  const locales = new Set(['en', 'tr', 'pt', 'de', 'ru', 'id', 'bn', 'es']);\n  const cache = new Map();\n  const pending = new Map();\n\n  return async function loadCatalog(locale) {\n    if (!locales.has(locale)) throw new Error('UNSUPPORTED_LOCALE');\n    const hit = cache.get(locale);\n    if (hit && now() - hit.at < (hit.value.partial ? 15_000 : 300_000)) {\n      return hit.value;\n    }\n    if (pending.has(locale)) return pending.get(locale);\n\n    const work = (async () => {\n      const response = await fetcher(`/demo/catalog.json?lang=${locale}`, {\n        credentials: 'omit',\n        cache: 'no-store',\n        signal: AbortSignal.timeout(8_000),\n      });\n      if (!response.ok) throw new Error('CATALOG_UNAVAILABLE');\n      const raw = await response.json();\n      if (!raw || raw.unavailable === true || typeof raw.partial !== 'boolean'\n        || !Array.isArray(raw.items) || raw.items.length > 10_000\n        || !raw.items.every(row => row && typeof row.id === 'string'\n          && typeof row.name === 'string')) {\n        throw new Error('CATALOG_UNAVAILABLE');\n      }\n      const value = Object.freeze({\n        partial: raw.partial,\n        items: Object.freeze(raw.items.map(row => Object.freeze({id: row.id, name: row.name}))),\n      });\n      cache.set(locale, {at: now(), value});\n      return value;\n    })();\n    pending.set(locale, work);\n    try { return await work; }\n    finally { pending.delete(locale); }\n  };\n}\n```\n\nThere 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.\n\nAn 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.\n\nKeep these states distinct:\n\n| State | Useful user-facing behavior | \n|---|---|\n| Browsing | Keep the server-rendered page and its links usable. | \n| Loading search | Announce loading through a status region. | \n| Complete search | Show matches; an empty message is now meaningful. | \n| Partial search | Show available matches with an explicit incomplete-data notice. | \n| Unavailable search | Explain that search could not load and offer retry. | \n\nA 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.\n\nIn 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.\n\nFirst 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.\n\nUse 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.\n\nTo 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.\n\nFor 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.\n\nThe 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.\n\nSources:", "url": "https://wpnews.pro/news/a-smaller-catalogue-page-without-losing-crawlable-navigation", "canonical_source": "https://dev.to/ar_karakaya_b6b310823e/a-smaller-catalogue-page-without-losing-crawlable-navigation-4c3g", "published_at": "2026-10-27 14:40:04+00:00", "updated_at": "2026-10-27 14:42:20.083454+00:00", "lang": "en", "topics": ["ai-crawlers", "structured-data", "developer-tools"], "entities": ["Google"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/a-smaller-catalogue-page-without-losing-crawlable-navigation", "markdown": "https://wpnews.pro/news/a-smaller-catalogue-page-without-losing-crawlable-navigation.md", "text": "https://wpnews.pro/news/a-smaller-catalogue-page-without-losing-crawlable-navigation.txt", "jsonld": "https://wpnews.pro/news/a-smaller-catalogue-page-without-losing-crawlable-navigation.jsonld"}}