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.
Options every helper takes
Section titled “Options every helper takes”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.@geoffy/headless/next
Section titled “@geoffy/headless/next”GeoffyProduct
Section titled “GeoffyProduct”<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.
createGeoffyProxyRoute(options)
Section titled “createGeoffyProxyRoute(options)”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.
createGeoffyRevalidateRoute(config)
Section titled “createGeoffyRevalidateRoute(config)”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.
@geoffy/headless/next-middleware
Section titled “@geoffy/headless/next-middleware”createGeoffyAiVisitMiddleware(options)
Section titled “createGeoffyAiVisitMiddleware(options)”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.
@geoffy/headless
Section titled “@geoffy/headless”The framework-agnostic client. You will use fetchGeoffyText for
robots.txt; the rest is what a Nuxt or SvelteKit adapter would be
built on.
| Export | Signature | Notes |
|---|---|---|
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) => string | Escapes < the way Next’s own example does |
compareCanonical | (page, artifact) => "match" | "mismatch" | "unknown" | Path only, not origin |
skippedMarker | (reason, detail) => string | The 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?) => ManifestDecision | Whether 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 | null | null for anything outside the namespace |
DEFAULT_GEOFFY_ORIGIN | "https://api.geoffy.ai" | |
PROXY_PREFIX | "/apps/geoffy" | The mount path is fixed; see the namespace |
GeoffyProductArtifact
Section titled “GeoffyProductArtifact”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;}Not published yet
Section titled “Not published yet”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.
Runtime
Section titled “Runtime”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.