A working example of using fontdue-js in a TanStack Start 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, React Router 7, 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 (
src/lib/graphql.ts+src/queries/*.graphql) — equivalent to theasync functionpattern in the Next.js App Router example, theloaderpattern in the React Router 7 example, and the frontmatter pattern in the Astro example. - CDN stale-while-revalidate caching on Netlify with on-demand purge via
/api/revalidate. - Tailwind for styling — page chrome uses utility classes; inline styles only where values come from the API (font-family, optical-adjustment, hero image dimensions).
cp .env.example .env
npm install
npm run devOpen http://localhost:3000.
The default .env.example points at https://example.fontdue.xyz, which has CORS allow-listed http://localhost:3000. 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. If port 3000 is taken and Vite bumps to a higher port, the client-side Relay subscriptions will fail CORS until your site allow-lists the new port.
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. -
src/routes/__root.tsx— the layout-level data layer. ItsloaderrunsloadFontdueProviderQuery()(theme/test-mode/tracking aux UI),loadCartButtonQuery()(cart count), andfetchGraphql<RootLayoutQuery>(…)(logo, nav pages, footer text, UI font, settings) in onePromise.all. The<FontdueProvider preloadedQuery={…}>commits the aux payload into the shared client Relay env on hydration so theme/banner/tracking render with no flash and no refetch.<StoreModal>is mounted with nopreloadedQuery— the modal is closed at SSR; cart data fetches lazily on open. Don't add a preload there.The same root file owns the page chrome:
head()returns meta/links,shellComponentrenders the<html>document,componentrenders the in-body layout (<FontdueProvider>→ header →<Outlet />→ footer), andnotFoundComponentrenders inside that layout when a child route throwsnotFound(). -
src/routes/index.tsx— per-page component preloads viacreateFileRoute('/').loader:export const Route = createFileRoute('/')({ loader: async () => { 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 }; }, component: Home, });
The component then reads loader data via
Route.useLoaderData()and renders<TypeTester preloadedQuery={preload} content="…" fontSize={48} />.loadTypeTesterQueryruns in the route loader (server during SSR).<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:
-
src/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. -
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--watchalongsidevite 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 const Route = createFileRoute('/')({
loader: async () => {
const data = await fetchGraphql<IndexQuery>('Index', IndexDoc);
const collections = data.viewer.fontCollections?.edges
?.flatMap((edge) => edge?.node ? [edge.node] : []) ?? [];
return { collections };
},
component: Home,
});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: src/routes/__root.tsx (logo, nav, settings), src/routes/index.tsx (collections list + preloaded testers for the first two), and src/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: [
devtools(),
netlify(),
tailwindcss(),
tanstackStart(),
viteReact(),
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 @netlify/vite-plugin-tanstack-start, which writes .netlify/v1/functions/server.mjs at build time. Netlify deploys that as the SSR request handler. (Netlify's framework auto-detection identifies TanStack Start 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:
dist/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 root route's loader sets the cache headers via a server-only function:
Netlify-CDN-Cache-Control: public, max-age=0, s-maxage=300, stale-while-revalidate=86400
Netlify-Cache-Tag: fontdue
The header set is wrapped in createServerFn so it runs only during SSR — TanStack Start route loaders run on both server (initial render) and client (subsequent navigation), and there's no response object to mutate from the client. Sequencing the server fn into the same Promise.all as the data fetches keeps total latency flat.
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/routes/api.revalidate.ts validates the token query param against REVALIDATE_TOKEN and calls Netlify's purgeCache({ tags: ['fontdue'] }). The route uses createFileRoute('/api/revalidate')({ server: { handlers: { POST } } }) — TanStack Start's API-route shape — and returns a Response with Cache-Control: no-store so the API endpoint itself isn't cached.
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.
- File-based routing under
src/routes/(mode: "file-router"in.cta.json). Routes use TanStack's flat dot notation:fonts.$slug.tsx→/fonts/$slug,api.revalidate.ts→/api/revalidate. - TanStack Start with the Vite plugin (
@tanstack/react-start/plugin/vite). defaultPreload: 'intent'is set insrc/router.tsx— links prefetch on hover so transitions feel instant.
This example targets fontdue-js v3, the release that introduced the framework-agnostic preload API.