A working example of using fontdue-js in a React Router 7 SSR app — 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 (Astro, TanStack Start, Vike, Remix, etc.).
- TypeTester islands server-rendered and hydrating without a refetch.
- The unified TypeTester entry (
fontdue-js/TypeTester) used directly from a route loader — same import path for both the loader (loadTypeTesterQuery) and the component (default export). - Backend URL configured once via
VITE_FONTDUE_URL. No URL passed at the call site. - Direct GraphQL fetches from route loaders alongside the fontdue-js preload helpers (
app/lib/graphql.ts+app/queries/*.graphql) — equivalent to theasync functionpattern in the Next.js App Router example and the frontmatter pattern in the Astro example. - CDN stale-while-revalidate caching on Netlify with on-demand purge via
/api/revalidate.
cp .env.example .env
npm install
npm run devOpen http://localhost:5173.
The default .env.example points at https://example.fontdue.xyz, which has CORS allow-listed http://localhost:5173. Point VITE_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—VITE_FONTDUE_URLis your Fontdue site's origin. TheVITE_prefix exposes it to client code (Vite convention); fontdue-js auto-readsPUBLIC_FONTDUE_URL/VITE_FONTDUE_URLfromimport.meta.envon both server and client. No explicitconfigure()call needed. -
app/root.tsx— preloads aux UI data (theme custom properties, test-mode flag, server-side tracking config) and the cart count, then renders<FontdueProvider preloadedQuery={…}>. 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. Theloaderhere also runs theRootLayoutGraphQL fetch so the header renders the dynamic logo, nav, and footer from the Fontdue admin. -
app/routes/home.tsx— per-page component preloads:export async function loader() { const indexData = await fetchGraphql<IndexQuery>("Index", IndexDoc); const collections = /* …extract from indexData… */; const testerPreloads = await Promise.all( testerCollections.map((c) => loadTypeTesterQuery({ familyName: c.featureStyle!.cssFamily!, styleName: c.featureStyle!.name! }), ), ); return { collections, testerCollections, testerPreloads }; }
The component then renders
<TypeTester preloadedQuery={preload} content="…" fontSize={48} />.loadTypeTesterQueryruns in the route loader (server).<TypeTester>server-renders as HTML, then hydrates 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.
Aside from the preload helpers, you can query viewer { … } from a route loader the same way the Astro example queries it from frontmatter or the Next.js example queries it from a server component. Three pieces:
-
app/lib/graphql.ts— a 25-linefetchGraphql<Q, V>(name, query, variables)helper.import.meta.env.VITE_FONTDUE_URLprovides the endpoint, so there's no hard-coded URL. -
app/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";
-
app/queries/operations-types.ts— generated by@graphql-codegen/clifrom the.graphqldocuments and the live schema (config incodegen.ts).npm run devruns codegen in--watchalongsidereact-router 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 a route loader:
export async function loader() {
const data = await fetchGraphql<IndexQuery>("Index", IndexDoc);
const collections = data.viewer.fontCollections?.edges
?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
return { collections };
}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 routes use this pattern: app/root.tsx (logo, nav, settings), app/routes/home.tsx (collections list + preloaded testers for the first two), and app/routes/fonts.$slug.tsx (font detail name/description/hero alongside <TypeTesters> / <CharacterViewer> / <BuyButton> islands).
vite.config.ts includes one fontdue-js-specific line:
import fontdueJs from "fontdue-js/vite";
export default defineConfig({
plugins: [tailwindcss(), reactRouter(), 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-interop, ssr.noExternal: ['fontdue-js'], optimizeDeps.include, and define: { global: 'globalThis' } to make those work.
This example is wired for Netlify SSR via the official adapter @netlify/vite-plugin-react-router, which generates .netlify/v1/functions/react-router-server.mjs at build time. Netlify deploys that as the SSR request handler. (Netlify's framework auto-detection identifies RR7 but does not wire up the function on its own — the plugin is required.)
To deploy a fork:
- Build settings (Netlify UI):
- Build command:
npm run build - Publish directory:
build/client - Functions directory: leave blank — the plugin writes to
.netlify/v1/functions/automatically.
- Build command:
- Environment variables: add
VITE_FONTDUE_URLunder Site configuration → Environment variables.VITE_*vars are inlined at build time, 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. The headers export in app/root.tsx 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), app/routes/api.revalidate.ts validates the token query param against REVALIDATE_TOKEN and calls Netlify's purgeCache({ tags: ['fontdue'] }). That route exports its own headers returning Cache-Control: no-store so the API endpoint itself isn't cached — RR7's leaf-route headers override the root's.
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.
ssr: trueinreact-router.config.ts— the preload runs in route loaders, which need SSR.- React Router 7.x with the Vite plugin (
@react-router/dev/vite).
This example targets fontdue-js v3, the release that introduced the framework-agnostic preload API.