A working example of using fontdue-js in an Astro site with full SSR — no client-side query refetch on hydration, no theming flash, no per-island duplicate fetches.
It uses fontdue-js's framework-agnostic preload API, so the same pattern works in any React-rendering SSR framework (React Router 7, TanStack Start, Vike, Remix, etc.).
- Multiple fontdue-js islands on one page (TypeTester / TypeTesters / CharacterViewer / BuyButton / TestFontsForm / NewsletterSignup / CartButton), all server-rendered, all hydrating without a refetch.
- The unified component entry (
fontdue-js/TypeTesteretc.) used directly from.astrofiles — same import path yields both the loader (load*Query) and the component (default export). - Backend URL set once via the
PUBLIC_FONTDUE_URLenv var; fontdue-js auto-reads it fromimport.meta.envon both server and client. Noconfigure()call. - Direct GraphQL fetches from Astro frontmatter alongside the fontdue-js preload helpers (
src/lib/graphql.ts+src/queries/*.graphql) — equivalent to theasync functionpattern in the Next.js App Router example. - Stale-while-revalidate caching at Netlify's CDN with on-demand
purgeCacheviaPOST /api/revalidatewired to Fontdue's deploy hook.
cp .env.example .env
npm install
npm run devOpen http://localhost:4321.
The default .env.example points at https://example.fontdue.xyz, which has CORS allow-listed http://localhost:4321. Point PUBLIC_FONTDUE_URL at your own Fontdue site if you have one — the client origin will need to be in your site's allowed origins.
Three files do the work:
-
.env—PUBLIC_FONTDUE_URLis your Fontdue site's origin. ThePUBLIC_prefix exposes it to client code (Astro convention); fontdue-js auto-readsPUBLIC_FONTDUE_URL/VITE_FONTDUE_URLfromimport.meta.envon both server and client. No explicitconfigure()call needed. -
src/layouts/Layout.astro— preloads aux UI data (theme custom properties, test-mode flag, server-side tracking config, cart count) and renders the<FontdueProvider>and<CartButton>islands plus a closed<StoreModal>. This is the layout-level Fontdue runtime; it commits the preloaded payload to the Relay env on hydration so theme/banner/tracking render without a flash.--- import 'fontdue-js/fontdue.css'; import { loadFontdueProviderQuery } from 'fontdue-js'; import FontdueProvider from 'fontdue-js/FontdueProvider'; const fontduePreload = await loadFontdueProviderQuery(); --- <html> <body> <FontdueProvider client:load preloadedQuery={fontduePreload} /> <slot /> </body> </html>
-
src/pages/index.astro— per-page component preloads:--- import TypeTester, { loadTypeTesterQuery } from 'fontdue-js/TypeTester'; import Layout from '../layouts/Layout.astro'; const preloaded = await loadTypeTesterQuery({ familyName: '…', styleName: '…' }); --- <Layout> <TypeTester client:load preloadedQuery={preloaded} content="…" fontSize={64} /> </Layout>
loadTypeTesterQueryruns in the Astro frontmatter (server).<TypeTester>renders server-side as HTML, thenclient:loadhydrates with the preloaded payload — no network call on hydration. Multiple islands on the same page share one Relay env + one Redux store via module-level singletons. The same<TypeTester>import works inside an existing<FontdueProvider>tree (e.g. Next layouts) using the lazy{familyName, styleName}shape — it auto-detects the parent provider and skips its own self-wrap.
Aside from the preload helpers, you can query viewer { … } from Astro frontmatter the same way the Next.js example queries it from a server component. Three pieces:
-
src/lib/graphql.ts— a 25-linefetchGraphql<Q, V>(name, query, variables)helper.import.meta.env.PUBLIC_FONTDUE_URLprovides the endpoint, so there's no hard-coded URL. -
src/queries/*.graphql— query documents (RootLayout,Index,Font). Imported with Vite's?rawsuffix so the string is inlined at build time:import IndexDoc from '../queries/Index.graphql?raw';
-
src/queries/operations-types.ts— generated by@graphql-codegen/clifrom the.graphqldocuments and the live schema (config incodegen.ts).npm run devruns codegen in--watchalongsideastro dev(vianpm-run-all), so editing a.graphqlfile regenerates types automatically.npm run codegenis the one-shot equivalent. The file is committed so contributors don't need a live Fontdue URL just to type-check. ThefetchGraphqlhelper takes the response and variables types as generics:fetchGraphql<FontQuery, FontQueryVariables>('Font', FontDoc, { slug });
Calling it from src/pages/index.astro:
---
import { fetchGraphql } from '../lib/graphql';
import IndexDoc from '../queries/Index.graphql?raw';
import type { IndexQuery } from '../queries/operations-types';
const data = await fetchGraphql<IndexQuery>('Index', IndexDoc);
const collections = data.viewer.fontCollections?.edges
?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
---The frontmatter is the SSR data layer — equivalent to the Next.js App Router's async function Page(). Run the GraphQL fetch and the fontdue-js preload helpers in the same Promise.all so the entire page costs one network round-trip:
const [layoutData, fontduePreload, cartPreload] = await Promise.all([
fetchGraphql<RootLayoutQuery>('RootLayout', RootLayoutDoc),
loadFontdueProviderQuery(),
loadCartButtonQuery(),
]);Three Astro routes use this pattern: src/layouts/Layout.astro (nav, settings, logo), src/pages/index.astro (collections list + preloaded testers for the first two), and src/pages/fonts/[slug].astro (font detail name/description/hero alongside <TypeTesters> / <CharacterViewer> / <BuyButton> islands).
astro.config.mjs includes one fontdue-js-specific line:
import fontdueJs from 'fontdue-js/vite';
export default defineConfig({
// …
vite: {
plugins: [fontdueJs()],
},
});Why: fontdue-js publishes per-file ESM (it isn't bundled at publish time). Some of its transitive deps (react-relay, relay-runtime, draft-js, fbjs) are CJS and use module.exports = require('./lib') re-export shapes that defeat strict ESM named-import resolution in both Node SSR and browser contexts. The fontdueJs() plugin wires up:
vite-plugin-cjs-interopto rewrite named imports of those deps to default-import + destructure.ssr.noExternal: ['fontdue-js']so the cjs-interop transform runs over fontdue-js's own source (otherwise Vite externalizes it and Node tries to named-import from CJS directly).optimizeDeps.includefor the same set plusreact-redux, so esbuild pre-bundles them for the browser.define: { global: 'globalThis' }becausefbjs(transitive viadraft-js) references Node'sglobalat module init.
output: 'server'(or'hybrid'with the page opted in) — the preload runs in frontmatter, which needs SSR.- An SSR adapter — this example uses
@astrojs/netlify. The fontdue-js integration itself is adapter-agnostic; only the cache headers insrc/middleware.tsand thepurgeCachecall insrc/pages/api/revalidate.tsare Netlify-specific. @astrojs/reactfor the React renderer.
This example is wired for Netlify SSR. To deploy a fork:
- Build settings (Netlify UI):
- Build command:
npm run build - Publish directory:
dist - Base directory and Functions directory: leave blank —
@astrojs/netlifywrites the SSR function to the right place automatically.
- Build command:
- Environment variables: add
PUBLIC_FONTDUE_URLunder Site configuration → Environment variables.PUBLIC_*vars are inlined at build time by Vite, so it must be set before the first build runs. Also setREVALIDATE_TOKENto a long random string — required by/api/revalidate(see below). - CORS allow-list: once Netlify gives you the deploy URL (e.g.
https://your-site.netlify.app), add it to your Fontdue site's allowed origins. If you want PR previews to work, allow-list thehttps://deploy-preview-*--your-site.netlify.apppattern too.
Pages are SSR but cached on Netlify's CDN with stale-while-revalidate, so requests after the first one are served from the edge in milliseconds. src/middleware.ts sets:
Netlify-CDN-Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Netlify-Cache-Tag: fontdue
After the SWR window the edge serves the stale copy and regenerates in the background — the user never waits for the upstream GraphQL call. To force a refresh on demand (e.g. when fonts change in your Fontdue admin), src/pages/api/revalidate.ts validates the token query param against REVALIDATE_TOKEN and calls Netlify's purgeCache({ tags: ['fontdue'] }).
Wire it up in Fontdue: Website settings → Deploy hook URL. Paste:
https://your-site.netlify.app/api/revalidate?token=YOUR_REVALIDATE_TOKEN
Fontdue will POST to this URL whenever you publish changes; cached HTML on Netlify's edge is invalidated and the next request regenerates against fresh data.
Today the Fontdue API doesn't include a collection id/slug in the deploy-hook payload, so every page is purged together. If/when it does, switch to per-collection tags (fontdue:${slug}) and purge selectively.
This example targets fontdue-js v3, the release that introduced the framework-agnostic preload API.