Password-protected collections

Protect a font collection with a password on a server-rendered Fontdue site, showing visitors a password form until they unlock it.

A password-protected collection is one you’ve given a password in the admin (under the collection’s settings). Until a visitor enters that password, the collection is hidden from your site – its page, its fonts, and its listings all behave as if it weren’t published. It’s how you share work in progress with a client, or withhold a release behind a shared password. On a script-tag site this needs no wiring: drop a <fontdue-node-password-form> element on the page and the Fontdue script detects the lock and handles the unlock. A server-rendered site renders the form itself, covered on this page.

Unlocking is per visitor and persists: once someone enters the right password the collection stays visible for them, while everyone else still sees the form.11The unlock is remembered in a first-party cookie the form sets in the browser, separate from your admin session.

Detecting the lock

When you fetch a collection the visitor hasn’t unlocked, createFontdueFetch throws a FontduePasswordProtectedError (from fontdue-js/server) instead of returning data. Catch it where you’d otherwise show a 404 and render <NodePasswordForm> (from fontdue-js/NodePasswordForm) instead – the collection exists, it’s just withheld. Pass the collection’s slug (or collectionId); the form submits the password, remembers the returned token, and reloads so the collection then resolves.

// src/app/fonts/[slug]/page.tsx
import { FontduePasswordProtectedError } from "fontdue-js/server";
import NodePasswordForm from "fontdue-js/NodePasswordForm";
export default async function FontPage({ params }) {
const { slug } = await params;
try {
const { viewer } = await getData(slug);
const collection = viewer.slug?.fontCollection;
if (!collection) notFound();
return <FontDetail collection={collection} />;
} catch (error) {
if (error instanceof FontduePasswordProtectedError) {
return <NodePasswordForm collectionSlug={slug} unlockEndpoint="/api/unlock" />;
}
throw error;
}
}

unlockEndpoint points at the unlock route covered in Forwarding the unlock below.

---
// src/pages/fonts/[slug].astro
import NodePasswordForm from "fontdue-js/NodePasswordForm";
import { FontduePasswordProtectedError } from "fontdue-js/server";
const { slug } = Astro.params;
let collection = null;
let locked = false;
try {
const data = await fetchGraphql("Font", FontDoc, { slug });
collection = data.viewer.slug?.fontCollection ?? null;
} catch (error) {
if (error instanceof FontduePasswordProtectedError) locked = true;
else throw error;
}
if (!locked && !collection) return new Response("Not found", { status: 404 });
---
{locked && <NodePasswordForm client:load collectionSlug={slug} />}
{collection && <FontDetail collection={collection} />}
// app/routes/fonts.$slug.tsx
import { FontduePasswordProtectedError } from "fontdue-js/server";
import NodePasswordForm from "fontdue-js/NodePasswordForm";
export async function loader({ params }: Route.LoaderArgs) {
try {
const data = await fetchGraphql("Font", FontDoc, { slug: params.slug });
const collection = data.viewer.slug?.fontCollection;
if (!collection) throw new Response("Not found", { status: 404 });
return { locked: false as const, collection };
} catch (error) {
if (error instanceof FontduePasswordProtectedError) {
return { locked: true as const, slug: params.slug };
}
throw error;
}
}
export default function FontDetail({ loaderData }: Route.ComponentProps) {
if (loaderData.locked) {
return <NodePasswordForm collectionSlug={loaderData.slug} />;
}
return <FontDetailView collection={loaderData.collection} />;
}
// src/routes/fonts.$slug.tsx
import { FontduePasswordProtectedError } from "fontdue-js/server";
import NodePasswordForm from "fontdue-js/NodePasswordForm";
export const Route = createFileRoute("/fonts/$slug")({
loader: async ({ params }) => {
try {
const data = await fetchGraphql("Font", FontDoc, { slug: params.slug });
const collection = data.viewer.slug?.fontCollection;
if (!collection) throw notFound();
return { locked: false as const, collection };
} catch (error) {
if (error instanceof FontduePasswordProtectedError) {
return { locked: true as const, slug: params.slug };
}
throw error;
}
},
component: FontDetail,
});
function FontDetail() {
const data = Route.useLoaderData();
if (data.locked) return <NodePasswordForm collectionSlug={data.slug} />;
return <FontDetailView collection={data.collection} />;
}

Forwarding the unlock

The form stores the unlock in a first-party cookie, but – just like admin preview – that token still has to reach the server fetch that renders the page, so the collection resolves on reload. fontdue-js does this for you.

In Next.js the unlock has two halves: forwarding the token, which is automatic, and taking the visitor past the static page cache, which needs one small route.

Forwarding first: when a server fetch comes back locked, createFontdueFetch reads the visitor’s unlock cookie and – if they’ve unlocked the collection – retries the fetch with the token, so the page fetch and the embedded server components (type testers, character viewer, buy button) all resolve. Non-protected pages never read the cookie and stay static and cacheable.22The same path as admin preview: createFontdueFetch folds a per-visitor token into the fetch automatically, so the render is dropped from the shared cache and never served to anyone else.

Font pages, though, are statically rendered, and Next’s full-route cache doesn’t vary on cookies – a locked collection’s page is cached as the password form, and no cookie could change what the cache serves after the visitor unlocks. The unlock route solves this the same way /api/preview does for admin preview: it enables draft mode, whose bypass cookie takes this one visitor’s requests past the static cache to dynamic renders, where the unlock cookie is read and the collection resolves. Mount it at the path the form’s unlockEndpoint names:

// src/app/api/unlock/route.ts
export { POST } from "fontdue-js/next/unlock";

On a successful unlock the form POSTs the token there before reloading, and the reload renders the unlocked collection. The bypass is scoped to the unlocked collection’s path, so only its pages render dynamically for that visitor – the rest of the site keeps serving from the static cache at full speed.33The bypass cookie lasts for the browser session – a returning visitor whose session has ended sees the form again and re-enters the password. Everyone else keeps the statically cached pages, including the form itself, which is the correct public view of a locked collection.

The unlock travels on the same request middleware as admin preview: runWithFontdue puts the visitor’s unlock token – and the admin token – into ambient scope for the whole render, so every server fetch and preload forwards it and the per-visitor render is forced out of the shared CDN cache. If you already mounted it for admin preview you’re done; if not, its setup shows where the middleware goes in each framework.44runWithFontdue composes runWithPreview (admin preview only) with runWithNodeAccess (unlocks only) – mount runWithNodeAccess on its own if you want unlocks without admin preview. And if you set your own CDN cache headers in middleware (see Revalidate data), don’t re-cache a response runWithFontdue already marked no-store, or you’d serve one visitor’s unlocked page to everyone.

1 The unlock is remembered in a first-party cookie the form sets in the browser, separate from your admin session. 
2 The same path as admin preview: createFontdueFetch folds a per-visitor token into the fetch automatically, so the render is dropped from the shared cache and never served to anyone else. 
3 The bypass cookie lasts for the browser session – a returning visitor whose session has ended sees the form again and re-enters the password. Everyone else keeps the statically cached pages, including the form itself, which is the correct public view of a locked collection. 
4 runWithFontdue composes runWithPreview (admin preview only) with runWithNodeAccess (unlocks only) – mount runWithNodeAccess on its own if you want unlocks without admin preview. And if you set your own CDN cache headers in middleware (see Revalidate data), don’t re-cache a response runWithFontdue already marked no-store, or you’d serve one visitor’s unlocked page to everyone.