Skip to content

The package

Three entry points matter to a Next.js app: @geoffy/headless/next for the framework adapters, @geoffy/headless for the framework-agnostic client underneath them, and @geoffy/headless/next-middleware for the optional AI visit tracking.

interface GeoffyClientOptions {
siteKey: string; // from Geoffy settings; public, addresses rather than authorises
origin?: string; // overrides GEOFFY_ORIGIN and the default, for this call
timeoutMs?: number; // how long before giving up and rendering without Geoffy
revalidateSeconds?: number; // how long your framework may reuse a cached copy
}

timeoutMs is deliberately short. These run during a build or an ISR revalidation, and the fallback (render without the widget) is cheap, while a stalled build is not.

revalidateSeconds is the backstop that makes the design self-correcting: if our purge call to your site is lost, the page still refreshes within this window on its own.

A missing or empty siteKey, handle or file name makes no request. The helper returns “no content” as if nothing were published, and the server log says so once:

@geoffy/headless: GEOFFY_SITE_KEY is not set, so Geoffy content is skipped. Set it to the site key from your Geoffy settings.
<GeoffyProduct siteKey={string} handle={string} canonicalUrl?={string} locale?={string} />

An async server component. Renders the widget and the structured data together, at the point you mount it. Renders nothing (no error, no fallback) when Geoffy has nothing published for the handle, is unreachable, when locale names a different language from the published content, or when canonicalUrl does not match the page Geoffy published against. In the last two cases it leaves an inert data-geoffy-skipped marker.

locale is the language this route renders, for example sr or de-DE. Only the language part is compared, so en-GB content renders on an en-US page. A value that is not a language tag is treated as unknown, and the component renders.

Accepts every GeoffyClientOptions field as a prop.

Pages with nothing to show cost no request

Section titled “Pages with nothing to show cost no request”

The component reads a small list of the products Geoffy has published, with the language and the path of each. It is cached with your root files and refreshed on every publish. With that list, the component declines a product page without asking Geoffy about the product: an unpublished product, a page in another language, or a page whose path is not the published one. There is nothing to configure.

If the list cannot be read, the component asks about the product directly, as older versions do. A problem on Geoffy’s side never hides a published product.

createGeoffyTextRoute(options, name, route?)

Section titled “createGeoffyTextRoute(options, name, route?)”
export const GET = createGeoffyTextRoute({ siteKey }, "llms.txt");

name is "llms.txt", "llms-full.txt" or "agents.md". Returns a route handler. Answers 503 with Retry-After when Geoffy is degraded, never a fabricated 404.

route takes one option, forwardCrawler, off by default. When it is on, the route tells Geoffy which AI agent asked for the file, by its agent name and never a raw user agent. Reading the request headers makes Next render the route on every request instead of serving it from its static or cached output, which is why it is off.

export const GET = createGeoffyProxyRoute({ siteKey });

The /apps/geoffy/[...path] catch-all. Query strings are not forwarded. It tells Geoffy which AI agent asked for a file, by the agent’s name only; a browser’s user agent is never passed on.

export const POST = createGeoffyRevalidateRoute({ secret, revalidateTag });

You pass revalidateTag in rather than the package importing it, so the package never depends on next/cache directly. Verifies the request signature for you. A product publish refreshes that product’s page; every publish, a guide included, refreshes llms.txt, llms-full.txt and agents.md.

Pass it bare. config.revalidateTag is typed (tag: string, profile?: string | { expire?: number }) => void, so both Next 15’s and Next 16’s version are assignable unwrapped, and the route supplies Next 16’s profile itself, { expire: 0 }, so the next request after a publish gets the new content instead of being served the old one while the refresh runs. Why.

proxy.ts
export const proxy = createGeoffyAiVisitMiddleware({ siteKey });

Optional. Reports the AI crawlers that read your pages and the shoppers who arrive from an AI assistant; every other request makes no network call. Returns a function (request, event?) => undefined, which Next reads as “carry on”. The report is handed to event.waitUntil, or to options.defer (Next’s after) when you pass one.

Takes siteKey, secret (default GEOFFY_REVALIDATE_SECRET), origin, clientIp, exclude, sample, timeoutMs and defer. With no secret, nothing is sent. AI visit tracking has the table and what each report carries.

Also exported: AI_ASSISTANT_HOSTS and BUILT_IN_AI_AGENT_SIGNATURES, the lists it matches against.

The framework-agnostic client. You will use fetchGeoffyText for robots.txt; the rest is what a Nuxt or SvelteKit adapter would be built on.

ExportSignatureNotes
fetchGeoffyText(options, name, request?) => Promise<string | null>null means unreachable. Accepts "robots-rules.txt", which the route factories do not. Pass request to tell Geoffy which AI agent asked
fetchGeoffyProduct(options, handle) => Promise<GeoffyProductArtifact | null>The raw artifact. null also when nothing is published for the handle
serializeJsonLd(node: unknown) => stringEscapes < the way Next’s own example does
compareCanonical(page, artifact) => "match" | "mismatch" | "unknown"Path only, not origin
skippedMarker(reason, detail) => stringThe inert data-geoffy-skipped comment
fetchGeoffyManifest(options, memoMs?) => Promise<GeoffyManifest | null>The list of published products, with the language and path of each. null on any failure
decideFromManifest(manifest, handle, page?) => ManifestDecisionWhether a page should ask for a product, from the manifest alone. reason is "not-published", "language-mismatch" or "canonical-mismatch"
handleGeoffyProxy(options, request) => Promise<Response>What the proxy route wraps. Takes the Request, not its URL
resolveProxyPath(requestUrl) => string | nullnull for anything outside the namespace
DEFAULT_GEOFFY_ORIGIN"https://api.geoffy.ai"
PROXY_PREFIX"/apps/geoffy"The mount path is fixed; see the namespace
interface GeoffyProductArtifact {
handle: string;
canonicalUrl: string; // the URL Geoffy published against
jsonLd: unknown; // schema.org Product node
widgetHtml: string; // styles included — do not sanitise them out
publishedAt: string | null;
}

When your site is verified but nothing is published for a handle, Geoffy answers with a successful { "handle": "blue-widget", "published": false }, not an error. Your framework can cache it like any other answer, and publishing the product purges it through the revalidate route. fetchGeoffyProduct returns null for it, so the component renders nothing.

Node 18 or newer: the package uses global fetch, AbortController and crypto.subtle. ESM only. React is an optional peer dependency, needed only for the /next entry point.