Documentation Menu

Next.js

9 min read

@quilletcms/sdk/next gives a Next.js App Router site a client whose every read is stored in Next's data cache under tags, so a webhook from Quillet can refresh exactly the pages a publish changed.

Why a cached page should last until a publish

A server-rendered page that asks the CMS on every request is slow and wasteful. A page that caches for an hour has the opposite problem: after a content manager publishes, it shows the old words for up to an hour. Next's data cache can hold a read until something says it is stale, and tags are how you say so. The adapter tags each read by what it read. Quillet's webhooks say what was published. Put together, the site keeps an answer as long as it is true and drops it the moment it is not.

If you are a content manager and your site is built this way, a publish shows on the live site within seconds of the webhook arriving, not at the next deploy, and that includes pages that use the document you published by reference. If a change does not appear, open the webhook's deliveries log under Settings → Webhooks. It says whether the site received the delivery.

What the adapter caches and under which tags

quilletNext(overrides, options) loads its configuration exactly as quillet() does. The client it builds differs in two ways: the SDK's own in-memory store is off, and every request goes through a fetch that tells Next how to cache it.

What is read Stored? Tags
A document, with get yes quillet, quillet:<project>/<env>, quillet:<key>
A listing, with list, tree or walk yes quillet, quillet:<project>/<env>, quillet:list:<prefix>
A search yes quillet, quillet:<project>/<env>, quillet:search:<project>/<env>
A read with at or preview no, no-store none
A file's bytes, through the proxy no, no-store none

Next keeps a stored read for revalidate seconds, 60 by default, and fetches it again on the next request after that. quilletNext({}, { revalidate: false }) keeps it instead until a tag naming it is revalidated. Every search shares one tag. The questions readers ask cannot be listed in advance, and a single event, a publish, makes all of their answers stale at once.

assetRoute(q) returns { GET, HEAD }: the private-file proxy as a route handler. It reads the file's id from the request's path rather than from params, so the route file needs nothing in it but the export.

How to

Build the client once, in a module that every server component imports:

// lib/quillet.ts: one client for the app, cached until a webhook says otherwise.
import { quilletNext } from '@quilletcms/sdk/next'

export const q = quilletNext({}, { revalidate: false })

Load a page through React's cache, so that generateMetadata and the page share one read and one Markdown render per request. Next deduplicates the fetch on its own, but not the render that follows it, which would otherwise run twice:

// lib/page.ts: a document and its HTML, once per request.
import { cache } from 'react'
import { q } from '@/lib/quillet'

export const loadPage = cache(async (key: string) => {
  const doc = await q.get<string>(key)
  return { doc, html: await q.renderMarkdown(doc.content, { headingIds: true }) }
})

Call it from a route's metadata, and from the page component in the same way. A document's title lives on its listing item, and the same request has already read that listing once, so asking for it again costs nothing. To pre-render every guide at build time, take the slugs from the folder's listing:

// app/docs/[slug]/metadata.ts: what app/docs/[slug]/page.tsx exports beside the page.
import { loadPage } from '@/lib/page'
import { q } from '@/lib/quillet'

export async function generateStaticParams(): Promise<{ slug: string }[]> {
  const listing = await q.list('docs/')
  return listing.items.map((item) => ({ slug: item.key.slice('docs/'.length) }))
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params
  const [{ doc }, listing] = await Promise.all([loadPage(`docs/${slug}`), q.list('docs/')])
  const item = listing.items.find((entry) => entry.key === doc.key)
  return { title: item?.title ?? slug, description: doc.content.split('\n\n', 1)[0] }
}

Serve private files from the path that assets.path names, /media by default. If the site checks who the reader is, put that check in front of the handler:

// app/media/[id]/route.ts: private files through the site, never the CMS's address.
import { assetRoute } from '@quilletcms/sdk/next'
import { q } from '@/lib/quillet'

export const { GET, HEAD } = assetRoute(q)

Then receive Quillet's webhooks and revalidate what each one names. In the dashboard, an owner adds the endpoint under Settings → Webhooks and subscribes it to the events content.published, content.unpublished, content.deleted, schema.published, asset.updated and asset.deleted. The owner then copies the signing secret into QUILLET_WEBHOOK_SECRET. The route checks the signature before it trusts the body:

// app/api/quillet/route.ts: verify, then revalidate the tags the event names.
import { createHmac, timingSafeEqual } from 'node:crypto'
import { revalidateTag } from 'next/cache'

function verified(raw: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(header.split(',').map((p) => p.split('=', 2) as [string, string]))
  const t = Number(parts.t)
  if (!Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false
  const expected = createHmac('sha256', secret).update(`${t}.${raw}`).digest()
  return header
    .split(',')
    .filter((p) => p.startsWith('v1='))
    .some((p) => {
      const given = Buffer.from(p.slice(3), 'hex')
      return given.length === expected.length && timingSafeEqual(given, expected)
    })
}

/**
 * The envelope names the published key and never the pages that reference it, so a site whose
 * pages use references cannot tell which pages a publish changed. Set this to false only when
 * no document in the environment holds a `{ "$ref" }`.
 */
const USES_REFERENCES = true

/** `docs/guides/intro` lives in the listings of `docs/guides/`, `docs/` and the root. */
function prefixesOf(key: string): string[] {
  const parts = key.split('/').slice(0, -1)
  return ['', ...parts.map((_, i) => `${parts.slice(0, i + 1).join('/')}/`)]
}

interface Envelope {
  type: string
  project: string
  env: string | null
  data: { items?: { key: string }[] }
}

export async function POST(request: Request): Promise<Response> {
  const raw = await request.text()
  const header = request.headers.get('quillet-signature') ?? ''
  if (!verified(raw, header, process.env.QUILLET_WEBHOOK_SECRET ?? '')) {
    return new Response('Invalid signature.', { status: 401 })
  }
  const event = JSON.parse(raw) as Envelope
  switch (event.type) {
    case 'content.published':
    case 'content.unpublished':
    case 'content.deleted':
      if (USES_REFERENCES) {
        revalidateTag(`quillet:${event.project}/${event.env}`)
        break
      }
      for (const { key } of event.data.items ?? []) {
        revalidateTag(`quillet:${key}`)
        for (const prefix of prefixesOf(key)) revalidateTag(`quillet:list:${prefix}`)
      }
      revalidateTag(`quillet:search:${event.project}/${event.env}`)
      break
    case 'schema.published':
      revalidateTag(`quillet:${event.project}/${event.env}`)
      break
    case 'asset.updated':
    case 'asset.deleted':
      revalidateTag('quillet')
      break
  }
  return new Response(null, { status: 204 })
}

The handler revalidates more than a single key, and the reason is how pages are stored. A page that inlines a referenced document, a component or a file is stored under the page's own key, not under the key of what it inlines. So a file change revalidates everything, and a schema change revalidates the whole environment. A content event does the same while USES_REFERENCES is true. The envelope says landing/hero was published, but not that pages/home references it, so revalidating quillet:landing/hero alone would leave pages/home stale until something else purged it.

The recipe revalidates the whole environment rather than falling back on a numeric revalidate. That way a publish appears on every page at once, and the price is that every stored read is fetched again once after each publish. A numeric revalidate would cache more, but it would leave a referencing page stale for that many seconds. Only a site with no references can set USES_REFERENCES to false and revalidate just the published key, the listings of its folders and the search tag.

A 204 tells Quillet the delivery succeeded. Answer with anything else and Quillet retries, up to six attempts over about fourteen and a half hours.

To refresh everything by hand, for example from an admin button, call revalidateTag('quillet').

Limits

  • The adapter is built for the App Router on the Node runtime. It reads .quilletrc and quillet.config.* from disk, and the Edge runtime cannot do that.
  • The package has no helper for verifying webhooks. The function above is all of it, and the webhooks guide covers the signature it checks.
  • A document's tag is quillet:<key>, with no project in it. An app that reads two projects sharing a key revalidates both when either one changes.
  • A webhook names only the documents that were published, never the documents that reference them. Revalidating quillet:<key> alone leaves stale every page that inlines that key. That is why the recipe revalidates the whole environment on content events while USES_REFERENCES is true.
  • A listing's tag is the prefix it was asked for. A narrow revalidation therefore has to cover every folder above the key, as prefixesOf does above.
  • Without webhooks, a stored read is refreshed only when revalidate runs out: after 60 seconds by default, and never with false.
  • invalidate on the client does nothing here, because the SDK's own store is off. Revalidate tags instead.
  • Previews and at reads are never stored, so a page in preview asks the CMS on every request.
  • asset.* events carry no environment. They revalidate the quillet tag, across every environment the app reads.

Last updated