Why static generation suits icon libraries
Why pre-rendering every icon page at build time suits icon libraries, how to manage build time for thousands of pages, and how to write their sitemaps.
6 min read
An icon library is mostly the same content for everyone: a page per icon with its name, a preview, download buttons and some metadata. Nothing on those pages depends on who's looking. That makes it a textbook case for static site generation, where each page is rendered to HTML once, at build time, and served as a file. This guide covers why it fits, what it costs once the page count reaches the thousands, and how to handle sitemaps at that scale.
Why static fits
Look at what changes, and how often:
| Content | Changes when | Where to render it |
|---|---|---|
| Icon pages | The icon set updates, maybe a few times a month | At build time |
| Category and listing pages | Same | At build time |
| Search results and filters | Every keystroke | In the browser |
| Chosen style, color and export size | Per visitor | In the browser |
| Downloads and copies | On click | In the browser |
Everything that varies per visitor can run in the browser. Everything else changes only when the data does. Rendering those pages on every request would repeat the same work thousands of times for identical output.
Static pages served from a CDN:
- respond from a nearby edge location without running any server code;
- have no cold starts and no database that can go down;
- arrive as complete HTML, which helps search engines and link previews;
- cost little to host, since serving a file is the cheapest thing a CDN does.
Generating thousands of pages
Most frameworks have a hook that lists the pages to generate. In the Next.js App Router, it's generateStaticParams:
// app/tokens/[id]/page.tsx
import { notFound } from 'next/navigation'
import { allTokens, findToken } from '@/data/tokens'
import { TokenPage } from '@/components/TokenPage'
export function generateStaticParams() {
return allTokens.map((token) => ({ id: token.slug }))
}
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const token = findToken(id)
if (token === undefined) notFound()
return <TokenPage token={token} />
}
Astro's getStaticPaths and other static generators follow the same shape: a list of parameters in, one HTML file per entry out.
Two habits keep this fast:
- Load the data once. Read the catalog from a local JSON file or module at build time. If each of 2,000 pages fetches from an API, that's 2,000 requests per build, and rate limits will eventually catch up with you.
- Keep pages self-contained. Each page should need only its own icon plus small lookups, such as related icons. Avoid passing the whole catalog into client components on every page, or every HTML file will carry a copy of it.
The build time trade-off
Build time grows roughly in proportion to the page count. If a page takes 50 ms to render, 2,000 pages take about 100 seconds of rendering alone, on every deploy, before you add social preview images or anything else generated per page.
When that becomes painful, the usual middle ground is to pre-render the popular pages and render the rest on demand. The first request for an uncommon page renders it on the server, and the result is cached and served as a static page after that. In Next.js, you return only part of the list from generateStaticParams and leave dynamicParams at its default of true:
export const dynamicParams = true // the default: unlisted ids render on first request
export function generateStaticParams() {
// The most visited icons; the long tail renders on demand.
return allTokens.slice(0, 300).map((token) => ({ id: token.slug }))
}
How on-demand pages are cached depends on your framework version and hosting, so check your platform's documentation before relying on it.
| Strategy | Build time | First visit to a rare page | Updating content |
|---|---|---|---|
| Pre-render everything | Longest | Instant | Rebuild |
| Pre-render popular pages, rest on demand | Short | One server render, then cached | Rebuild or revalidate |
| Render on every request | None | A server render every time | Always current |
Deploy size is the other cost: multiply the size of one HTML page by the number of pages. For a few thousand pages that's rarely a problem, but check it if each page embeds a lot of data.
If you want no server work at all, for example because your host only serves files or you have no budget for serverless functions, go one step further and export the whole site as files. In Next.js that is output: 'export' in next.config. The build writes plain HTML to an out folder, every dynamic route must list all of its params in generateStaticParams with dynamicParams set to false, and anything that needs a server is unavailable: on-demand rendering, revalidation, built-in image optimization, and headers or redirects defined in next.config. Set cache headers and redirects in your host's configuration instead. For an icon library, that's usually an easy trade.
Updating when the data changes
Static pages are only as current as the last build. For an icon library that's usually fine: when the icon set updates, rebuild and redeploy. If you need faster updates without a full build, many frameworks offer revalidation, on a timer or triggered by a webhook, which re-renders specific pages in the background.
The SVG files are static too. Serve them with long-lived cache headers, and make sure a rebuild can't leave a fresh page pointing at a stale cached file, for example by versioning the file URLs. The guide on caching SVG files covers the headers.
Sitemaps for thousands of pages
A sitemap tells search engines which pages exist. The sitemap protocol allows up to 50,000 URLs and 50 MB uncompressed per file; beyond that, you split the list into several sitemaps and list those in a sitemap index. A library with a few thousand pages fits comfortably in one file.
In Next.js, an app/sitemap.ts file can generate it from the same data as the pages:
import type { MetadataRoute } from 'next'
import { allTokens } from '@/data/tokens'
const BASE = 'https://example.com'
export default function sitemap(): MetadataRoute.Sitemap {
return [
{ url: BASE },
...allTokens.map((token) => ({
url: `${BASE}/tokens/${token.slug}`,
lastModified: token.updatedAt,
})),
]
}
A few things make the sitemap useful rather than noise:
- Honest
lastmoddates. Use the date the page's content actually changed, not the build time. A date that moves on every deploy, for every page, tells search engines nothing. - Canonical URLs only. List each page once, at the address you want indexed. If case-insensitive slugs or query parameters can produce the same page at several URLs, point them at one canonical URL with a canonical link element, and list only that one.
- Skip
priorityandchangefreq. Google has said it ignores both; an accuratelastmodis what it can use. - Distinct titles and descriptions. Thousands of pages that differ only in a name can look thin. Give each page specifics: the icon's name and category in the title, the styles available, related icons.
What stays in the browser
Static generation handles the pages; the interactive parts still run on the client. Searching a few thousand entries is fast enough in the browser that you don't need a search server; see client-side search for icon libraries. Downloads and copies happen in click handlers. And the grid of all icons needs care of its own once it holds thousands of entries, which rendering thousands of icons covers.