Build font pages

Render one detail page per collection with a dynamic route that fetches a collection by slug and renders it.

This is where querying the catalog pays off: one file becomes every typeface’s page. A dynamic route fetches a single collection by its slug and renders it, and because the slug is the only thing that varies, that one route covers your whole catalog. It builds on the fetchGraphql helper and generated types from Query the GraphQL API.

The dynamic route

The route’s slug segment is dynamic – /fonts/example-serif and /fonts/example-sans both resolve to one file, with the matched value handed in. It reads the slug, fetches that collection, and renders it; if nothing matches, it returns a 404.

The [slug] folder makes the segment dynamic. The page fetches the collection on the server and renders your own FontDetail component; a missing collection calls notFound(). Because the page is a server component, the Fontdue components inside FontDetail render server-side from their ids – there’s no preloading to do here.

The route’s loader does the fetching. It runs the page query and each Fontdue component’s load*Query preload together in one Promise.all, so the page and its components load in a single server round-trip. A missing collection throws a 404 response.

// src/app/fonts/[slug]/page.tsx
import { notFound } from "next/navigation";
import { fetchGraphql } from "@/lib/graphql";
import { FontQuery, FontQueryVariables } from "@graphql";
import FontDetail from "@/components/FontDetail";
interface FontProps {
params: Promise<{ slug: string }>;
}
export default async function Font({ params }: FontProps) {
const { slug } = await params;
const data = await fetchGraphql<FontQuery, FontQueryVariables>("Font.graphql", { slug });
const collection = data.viewer.slug?.fontCollection;
if (!collection) notFound();
return <FontDetail collection={collection} />;
}
// app/routes/fonts.$slug.tsx
import type { Route } from "./+types/fonts.$slug";
import { fetchGraphql } from "../lib/graphql";
import FontDoc from "../queries/Font.graphql?raw";
import type { FontQuery, FontQueryVariables } from "../queries/operations-types";
import { loadTypeTestersQuery } from "fontdue-js/TypeTesters";
import { loadCharacterViewerQuery } from "fontdue-js/CharacterViewer";
import { loadBuyButtonQuery } from "fontdue-js/BuyButton";
export async function loader({ params }: Route.LoaderArgs) {
const [fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] =
await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>("Font", FontDoc, { slug: params.slug }),
loadTypeTestersQuery({ collectionSlug: params.slug }),
loadCharacterViewerQuery({ collectionSlug: params.slug }),
loadBuyButtonQuery({ collectionSlug: params.slug }),
]);
const collection = fontData.viewer.slug?.fontCollection;
if (!collection) throw new Response("Not found", { status: 404 });
return { collection, typeTestersPreload, characterViewerPreload, buyButtonPreload };
}
// src/routes/fonts.$slug.tsx
import { createFileRoute, notFound } from "@tanstack/react-router";
import { fetchGraphql } from "../lib/graphql";
import FontDoc from "../queries/Font.graphql?raw";
import type { FontQuery, FontQueryVariables } from "../queries/operations-types";
import { loadTypeTestersQuery } from "fontdue-js/TypeTesters";
import { loadCharacterViewerQuery } from "fontdue-js/CharacterViewer";
import { loadBuyButtonQuery } from "fontdue-js/BuyButton";
export const Route = createFileRoute("/fonts/$slug")({
loader: async ({ params }) => {
const [fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] =
await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>("Font", FontDoc, { slug: params.slug }),
loadTypeTestersQuery({ collectionSlug: params.slug }),
loadCharacterViewerQuery({ collectionSlug: params.slug }),
loadBuyButtonQuery({ collectionSlug: params.slug }),
]);
const collection = fontData.viewer.slug?.fontCollection;
if (!collection) throw notFound();
return { collection, typeTestersPreload, characterViewerPreload, buyButtonPreload };
},
component: FontDetail,
});
---
// src/pages/fonts/[slug].astro
import { fetchGraphql } from "../../lib/graphql";
import FontDoc from "../../queries/Font.graphql?raw";
import type { FontQuery, FontQueryVariables } from "../../queries/operations-types";
import { loadTypeTestersQuery } from "fontdue-js/TypeTesters";
import { loadCharacterViewerQuery } from "fontdue-js/CharacterViewer";
import { loadBuyButtonQuery } from "fontdue-js/BuyButton";
const { slug } = Astro.params;
if (!slug) return Astro.redirect("/");
const [fontData, typeTestersPreload, characterViewerPreload, buyButtonPreload] =
await Promise.all([
fetchGraphql<FontQuery, FontQueryVariables>("Font", FontDoc, { slug }),
loadTypeTestersQuery({ collectionSlug: slug }),
loadCharacterViewerQuery({ collectionSlug: slug }),
loadBuyButtonQuery({ collectionSlug: slug }),
]);
const collection = fontData.viewer.slug?.fontCollection;
if (!collection) return new Response("Not found", { status: 404 });
---

The Font query

Font resolves one collection through the slug lookup and pulls in the fields the page renders:

# Font.graphql
query Font($slug: String!) {
viewer {
slug(name: $slug) {
fontCollection {
id
name
description
fontStyles {
name
webfontSources {
url
format
}
}
images {
url
description
}
pageMetadata {
title
description
keywords
}
}
}
}
}

The query asks for the collection’s id and name, its description, each style with the webfont sources used to display it, the images, and the metadata.11The template’s real query pulls in more – the feature style, child families for superfamilies, SKUs, variable instances – so one component can render a single family, a superfamily, or a variable font from the shape it’s handed. The full version is in the repo.

In the Next.js template the fields the FontDetail component renders are factored into a FontDetail.fragment.graphql fragment and pulled in with #import, so the font page and the featured family on the home page always request an identical shape from one definition. See Share GraphQL fragments across queries.

Pre-rendering every page

generateStaticParams makes the route data-driven: it fetches the list of slugs – a small FontPaths query that asks only for slug { name } – and returns one entry per collection, so Next.js builds a static page for each. Add a collection in the admin and its page appears at the next build, or sooner with a revalidation.

export async function generateStaticParams(): Promise<{ slug: string }[]> {
const data = await fetchGraphql<FontPathsQuery>("FontPaths.graphql");
return (
data.viewer.fontCollections?.edges
?.map((edge) => edge?.node?.slug?.name)
.filter((slug): slug is string => slug != null)
.map((slug) => ({ slug })) ?? []
);
}

Your template renders each font page on demand rather than building a fixed list ahead of time, and stays fast through CDN caching – see Revalidate data. To pre-render the pages instead, reach for your framework’s own static-export step.

Metadata per page

The same data drives each page’s SEO. generateMetadata takes the collection’s pageMetadata if set and falls back to its name:

// same file as the route above
export async function generateMetadata({ params }: FontProps): Promise<Metadata> {
const { slug } = await params;
const data = await fetchGraphql<FontQuery, FontQueryVariables>("Font.graphql", { slug });
const font = data.viewer.slug?.fontCollection;
if (!font) notFound();
return {
...font.pageMetadata,
title: font.pageMetadata?.title ?? font.name,
alternates: { canonical: `/fonts/${slug}` },
};
}

Set the page title and description from the same pageMetadata your loader already fetched, through React Router’s meta export.

Set the page title and description from the same pageMetadata your loader already fetched, through TanStack Start’s route head option.

Set the page title and description from the same pageMetadata your frontmatter already fetched, with a <title> and meta tags in the page head.

Rendering the collection

FontDetail is your own component, not one from fontdue-js. It lays out the page and drops in the Fontdue components keyed by collection.id – the lazy id form, fed straight from the query:

The route’s component reads the preloads from its loader and passes each to the matching component as preloadedQuery. The data is already fetched, so the components render filled-in from the first paint:

Render each component as an island with client:load, passing the preload from the frontmatter and the shared config:

// inside FontDetail, given `collection`
import TypeTesters from "fontdue-js/TypeTesters";
import BuyButton from "fontdue-js/BuyButton";
<h1>{collection.name}</h1>
<BuyButton collectionId={collection.id} collectionName={collection.name} />
<TypeTesters collectionId={collection.id} />
// the route component, given loaderData
<h1>{collection.name}</h1>
<BuyButton preloadedQuery={buyButtonPreload} collectionName={collection.name} />
<TypeTesters preloadedQuery={typeTestersPreload} />
<CharacterViewer preloadedQuery={characterViewerPreload} />
// the route component, given loaderData
<h1>{collection.name}</h1>
<BuyButton preloadedQuery={buyButtonPreload} collectionName={collection.name} />
<TypeTesters preloadedQuery={typeTestersPreload} />
<CharacterViewer preloadedQuery={characterViewerPreload} />
<h1>{collection.name}</h1>
<BuyButton client:load preloadedQuery={buyButtonPreload} collectionName={collection.name} config={config} />
<TypeTesters client:load preloadedQuery={typeTestersPreload} config={config} />
<CharacterViewer client:load preloadedQuery={characterViewerPreload} config={config} />

To show a font’s name set in its own typeface, the templates wrap text in a small component backed by the useFont hook, which loads the style’s webfontSources in the browser. For the page’s own UI font – the chrome that should be styled before anything hydrates – write the @font-face yourself and preload it, covered in Write your own @font-face CSS.

Images

Collections carry images – the hero shots and specimens on a font page. How they’re served splits by framework.

withFontdue wires up a Cloudflare image loader, so next/image resizes and reformats your collection images automatically. Set NEXT_PUBLIC_FONTDUE_IMAGE_HOST to your image-transformation host, and NEXT_PUBLIC_FONTDUE_IMAGE_ORIGINS to the hostnames it’s allowed to fetch from, to turn it on; without them, images are served as-is.

Your template renders images with a plain <img> tag and no optimization layer. Serve appropriately sized images, or add your framework’s own image handling if you need transforms.

1 The template’s real query pulls in more – the feature style, child families for superfamilies, SKUs, variable instances – so one component can render a single family, a superfamily, or a variable font from the shape it’s handed. The full version is in the repo.