Next.js quickstart
Everything on this page is server-side. There are no hooks and no client components, because the point is to be read by crawlers that do not run JavaScript.
Add Geoffy to my storefront: geoffy.ai/docs/SKILL.md Or paste the whole prompt — for an agent that cannot fetch a URL
---
name: geoffy-headless
description: Add Geoffy's AI-discovery surfaces to a custom-built Next.js or Astro storefront — the product widget, schema.org data, llms.txt, plain-text product twins, buying guides and crawler rules, all served from the merchant's own domain.
---
# Add Geoffy to a custom-built storefront
Geoffy publishes a discovery layer for a product catalogue — structured data, plain-text
twins of every product, buying guides, `llms.txt` — served from **the merchant's own
domain**, so the citations it earns are theirs.
You are adding the integration. Read all of this before you touch a file.
---
## What you can do, and what you cannot
**Say this to the user before you start.** It prevents the failure that otherwise wastes
an hour at the end.
You can do: install the package, write every route and component, merge `robots.txt`, and
verify the markup is server-rendered.
You **cannot** do, because they need a human or a live site:
| Blocker | Why |
|---|---|
| Getting a `GEOFFY_SITE_KEY` | Needs a Geoffy account with a connected site |
| Proving domain ownership | Geoffy fetches their **live, public https** site. Localhost cannot satisfy it |
| Generating and publishing content | Costs money. That is the user's decision, not yours |
⚠️ **Until ownership is verified and something is published, the integration renders
NOTHING.** Before verification every Geoffy endpoint returns 404. After verification, a
product with nothing published answers `published: false`, and the component renders nothing
for it. Both are correct behaviour, not bugs. Do not "fix" working code because nothing
appears. See *Critical rules*.
---
## Before you start
Show the user this checklist and **wait for a yes**:
1. This adds Geoffy to your **custom-built storefront** (your own Next.js or Astro app).
If you run a themed Shopify store or WordPress, this is the wrong integration — use
your Geoffy dashboard instead.
2. You have a `GEOFFY_SITE_KEY` from your site's settings in Geoffy. If not, get one
first: it is free, and nothing below works without it.
3. Your site is deployed at a public https domain, or will be. Geoffy verifies ownership
by fetching it.
4. I will add routes and one component, and modify your `robots.txt`. I will not change
your product page's existing markup.
If they have no site key, stop. Point them at <https://geoffy.ai/docs/headless> and offer
to continue once they have one.
---
## Step 1: Read the project before you ask anything
Derive everything you can. Only ask about what you genuinely cannot determine — see
Step 2.
| Determine | How |
|---|---|
| Framework | `next` or `astro` in `package.json` dependencies |
| Package manager | Lockfile: `bun.lock` → bun, `pnpm-lock.yaml` → pnpm, `yarn.lock` → yarn, else npm |
| Next.js router | `app/` directory → App Router. **Pages Router is not supported** — stop and say so |
| Source root | `src/app` and `src/pages` are as common as the root forms. Match what exists |
| TypeScript | `tsconfig.json` present |
| Product route | Glob for a dynamic segment under a products-ish path, e.g. `app/**/products/[*]/page.tsx`, `src/pages/**/products/[*].astro` |
| Existing canonical | Does the product page set `alternates.canonical` (Next) or `<link rel="canonical">` (Astro)? |
| Existing Product JSON-LD | Grep the product page for `"@type": "Product"` (or `"@type":"Product"`) or an imported helper that builds one (`productJsonLd`, `ProductJsonLd`, …). Note whether it carries an `@id` |
| Existing robots | `app/robots.ts`, `app/robots.txt/route.ts`, `src/pages/robots.txt.ts`, or `public/robots.txt` |
| Astro render mode | `output` in `astro.config.*`, and whether an adapter is configured |
| Locales | A `[lang]`/`[locale]` segment, or an i18n config |
| Commerce backend | Shopify client packages in `package.json` (`@shopify/storefront-api-client`, `shopify-buy`, …) |
Report what you found in two or three lines. Do not ask the user to confirm things you
just read off disk.
---
## Step 2: Ask the open questions — once, together, with a recommendation
Some choices change the code and **cannot** be derived. Ask about those, and only those.
**Ask them all in ONE message, before writing any code.** Do not interleave questions with
edits: the user answers once and you work uninterrupted, and they can see the whole shape
of the change before it starts.
For each question: state what you found, recommend one option **with the reason from their
codebase**, and list the real alternatives. Format:
```
Q1. <the question>
Found: <evidence from their project>
Recommend: <option> — <why, given that evidence>
Alternatives: <option> — <when that is the right call>
```
End with: *"Reply with your choices, or say 'recommended' to take all of them."*
### The questions that are worth asking
Ask a question only if the trigger applies. Skip the rest silently.
**Q. Which kind of site is this?** — *always ask, unless Shopify packages make it obvious.*
Decides what `handle` means and how their catalogue reaches Geoffy.
- *Shopify behind your storefront* — products already synced; `handle` is the Shopify
product handle.
- *No commerce platform* — Geoffy crawls their product pages; `handle` is the segment
their URL pattern captures, shown in the dashboard.
- Recommend the first if you found a Shopify client, the second otherwise, and say which
package you saw.
**Q. Is `<found path>` the product page?** — *ask when zero or multiple candidates match.*
With one confident match, say which file you will edit and move on.
**Q. Where should the widget appear?** — *always ask.* It is a visual decision on their
page and you should not make it silently. Name the components you found in their product
page and recommend a position — usually after the description and before reviews or
related products. Say that the position is entirely theirs and Geoffy only checks that it
is present.
**Q. Your Astro site is `output: 'static'`. Switch to on-demand rendering?** — *Astro only,
when static.* A static build **cannot serve the `/apps/geoffy` namespace at all**, and its
content only updates on a rebuild.
- *Add an adapter and serve on demand* — recommend this if their catalogue changes;
they get the namespace and hourly refresh.
- *Stay static* — recommend if the catalogue is genuinely fixed. Then **skip Step 6
entirely** and tell them their guides and product twins stay on Geoffy's domain rather
than theirs.
- This one has a real cost either way. Do not decide it for them.
**Q. Mount the revalidate route?** — *Next.js only.* Publishing reaches their pages within
an hour without it; this makes it seconds. Recommend yes — it is one file (code in Step 5,
*The revalidate route*) — and note it needs a secret from their Geoffy settings.
**Q. Your site has locales. Which one is Geoffy publishing for?** — *only when you found a
locale segment.* Geoffy publishes against **one canonical URL per product**, in one
language. Recommend passing both `locale` (the locale the route renders) and `canonicalUrl`.
Then the component renders only on the published locale and nothing on the others, so
mounting it everywhere is safe. On the other locales it makes no request to Geoffy. This
needs `@geoffy/headless` 0.2.0 or later; older versions ignore `locale`.
After mounting, **verify this on a non-canonical locale directly** rather than trusting
it from reading the code — `curl` the same product on a locale that is *not* the
published one and confirm no `data-geoffy` markup and no Geoffy `Product` node appear.
The safety mechanism depends on the `canonicalUrl` you pass actually differing per
locale (i.e. on a real per-locale canonical, not one URL reused across all of them); if
it does not differ, every locale looks like a match and the published locale's widget
and JSON-LD render under every translation. Passing `locale` catches that case too, as long
as the published content's language differs from the page's. This has been seen to slip
through in practice, so treat it as unverified until you have curled it.
**Q. You already have `<robots file>`. Replace it with a route that merges Geoffy's rules?**
— *only when a robots file exists.* Their own rules are preserved either way; you are
changing how the file is produced. Recommend yes, and show them the merged result before
deleting anything.
**Q. Your product page already emits a `Product` JSON-LD node. What happens to it?** —
*only when Step 1 found one.* This is not a decision to make silently, and the answer is
almost never "remove it" — see *Two `Product` nodes on one page* below for the full
reasoning. Format it like the others:
```
Found: <file/helper> emits its own `Product` node<, with @id <value>> for <fields it carries
— price, offers, availability, sku, brand, …>.
Recommend: Keep it exactly as it is. Geoffy's node is a supplement, not a replacement — it
never emits offers/price/availability, so the native node is the page's ONLY source
for those. Two Product nodes with different @ids is valid schema.org, not duplicate
data.
Alternatives: none that are safe. Commenting the native node out to "avoid duplication" is
the one thing to actively avoid — it removes commerce data Geoffy will never supply.
```
Do not ask this as an open question the way the others are asked — state the recommendation
and proceed unless the user pushes back. There is no legitimate alternative today.
### Do not ask about
Anything in the Step 1 table. Package manager, framework, router, TypeScript, file
locations, whether they use `src/` — read it. A question you could have answered yourself
reads as not having looked.
---
## Step 3: Install
Use their package manager.
```bash
npm install @geoffy/headless # or pnpm add / yarn add / bun add
```
Node 18 or newer. ESM only. React is an optional peer, needed only for the Next.js entry.
---
## Step 4: Environment
```bash
# .env — from their site's settings in Geoffy. Public; safe in a build.
GEOFFY_SITE_KEY=hs_live_...
```
Never invent a key. If they have not given you one, stop and ask.
Next.js only, if they said yes to the revalidate route:
```bash
GEOFFY_REVALIDATE_SECRET=...
```
Leave `GEOFFY_ORIGIN` unset unless the user is pointing the build at something other than
`https://api.geoffy.ai` — most commonly a locally-run Geoffy API during development against
that codebase, not something an ordinary merchant integration needs:
```bash
GEOFFY_ORIGIN=http://localhost:3000
```
It is real but undocumented on the public site — it exists as a code comment in the
package's client, not in any guide a merchant would read. Only reach for it when the user
tells you they are developing against a non-production Geoffy API.
Add both `GEOFFY_SITE_KEY` and `GEOFFY_REVALIDATE_SECRET` to `.env.example` if one exists.
`GEOFFY_ORIGIN` does not belong there — it is a local escape hatch, not a value a real
deployment should ever set. Never commit real values.
---
## Step 5: The product page
Mount at the position they chose in Step 2. Pass the canonical they already build — do not
construct a second copy of that logic.
**Next.js (App Router):**
```tsx
import { GeoffyProduct } from "@geoffy/headless/next";
// inside the product page component, where they chose:
<GeoffyProduct
siteKey={process.env.GEOFFY_SITE_KEY!}
handle={handle}
locale={locale} // only if the site has locales: the locale this route renders
canonicalUrl={canonical}
/>
```
It is an async **server component**. It renders the widget and the structured data
together, and renders nothing when Geoffy has nothing published or is unreachable. Mount it
on every product page: a page for an unpublished product makes no request to Geoffy.
**Astro:**
```astro
---
import { getGeoffyProductMarkup } from "@geoffy/headless/astro";
const geoffy = await getGeoffyProductMarkup(
{ siteKey: import.meta.env.GEOFFY_SITE_KEY },
handle,
{ canonicalUrl: canonical, locale }, // locale: only if the site has locales
);
---
{geoffy && <Fragment set:html={geoffy.jsonLdScript} slot="head" />}
{geoffy && <div data-geoffy-widget set:html={geoffy.widgetHtml} />}
```
`slot="head"` only reaches the head if their `Layout.astro` declares `<slot name="head" />`
inside its `<head>`. **Check.** If it does not, either add it or say the structured data
will render in the body — which still works.
If the product page has no canonical at all, add one. Without it Geoffy refuses to publish,
and in the Next.js App Router a missing `generateMetadata` means every product page
inherits the layout's metadata and claims to be the home page.
---
### The revalidate route (Next.js only, if the user said yes in Step 2)
What it is: Geoffy **calls this**, not the other way round. Whenever a product or a guide
changes, it POSTs an HMAC-signed request to `https://<verified-domain>/api/geoffy/revalidate`
so the merchant's ISR cache purges within seconds instead of on its own schedule
(`revalidateSeconds`, default one hour). It is purely an optimisation — losing it degrades
"seconds" back to "within the hour", nothing more, so a missed call is never the reason
content fails to go live.
Every publish, a product or a guide, also refreshes `llms.txt`, `llms-full.txt` and
`agents.md`. Before `@geoffy/headless` 0.2.0 a guide publish did not, and a new guide stayed
missing from `llms.txt` for up to the revalidate window. If the project pins an older
version, upgrade it.
```ts
// app/api/geoffy/revalidate/route.ts <- this exact path; Geoffy calls it, not you
import { revalidateTag } from "next/cache";
import { createGeoffyRevalidateRoute } from "@geoffy/headless/next";
export const POST = createGeoffyRevalidateRoute({
secret: process.env.GEOFFY_REVALIDATE_SECRET!,
revalidateTag,
});
```
Pass `revalidateTag` **bare, straight from `next/cache`**, and do not wrap it. Next 16 takes a
second argument — a cache-life profile — and **the package supplies it**: `{ expire: 0 }`, which
is Next's own guidance for an invalidation arriving from outside a Server Action, where
`updateTag` is unavailable. The config type takes that second parameter as optional, so Next
15's one-argument `revalidateTag` and Next 16's two-argument one are both assignable bare.
Do not add a profile of your own here. `"max"`, which Next's examples recommend, means the first
visitor after a publish is still served the **pre-publish** page while the refresh runs behind
it — the exact delay this route exists to remove. If you are on `@geoffy/headless` 0.1.0 or
earlier the package still makes the deprecated one-argument call, which Next treats as
`{ expire: 0 }` anyway; upgrade the package rather than wrapping the function.
- **The path is part of the contract.** Geoffy builds the address it calls from the domain
verified in Step 11 — there is no field anywhere to override it. Mounting the handler
anywhere else means nothing ever calls it, silently, because a missed invalidation is
survivable and is never reported as a publish failure.
- `GEOFFY_REVALIDATE_SECRET` is issued by Geoffy and shown on the site's settings page —
copy it, never invent it (see Step 4).
- If `next build` reports a type error assigning `revalidateTag`, that is a real signal the
installed `@geoffy/headless` version predates the Next 16 fix — upgrade the package rather
than reaching for a wrapper. A wrapper compiles, so it hides the version skew instead of
reporting it.
---
### Two `Product` nodes on one page — and why that is correct, not a bug to fix
A storefront that already renders its own `Product` JSON-LD is common, and adding
`<GeoffyProduct>` puts a **second** `Product`-typed node on the same page. The instinct is
to treat this as duplicate data and remove one of them. Do not act on that instinct.
**Geoffy's node is a content supplement, never a commerce record.** By design it carries
its own `@id` — `<canonical-url>#geoffy-enrichment` — and it never emits `offers`, `price`,
`priceCurrency`, or `availability`. Those fields belong to the storefront's own node, which
is live and changes on every sale; Geoffy's is generated periodically and would go stale
the moment it tried to own a price. So the native node is not redundant with Geoffy's — it
is the page's **only** source for anything commerce-shaped. Comment it out, and the page
still has a Product node, but one that can no longer say what the product costs or whether
it is in stock.
Two `Product`-typed nodes with **different** `@id`s is valid schema.org and unambiguous to
a parser — each node identifies something distinct. This is a settled design decision on
Geoffy's side: an earlier version tried to make the two
nodes merge by reusing the native node's `@id`, and Google's Rich Results Test showed it
does not merge same-`@id` nodes across separate `<script>` tags — it surfaced Geoffy's node
as a second, nameless product instead, which is worse than two clearly separate nodes.
**On a custom-built storefront, the two nodes cannot be linked yet, and that is expected.**
Geoffy's server-side emitter supports an `about: { "@id": nativeNodeId }` field that
associates its node with the storefront's own — but that requires the platform to tell
Geoffy what the native node's `@id` is, and today that detection only exists for the
Shopify and WooCommerce publish paths (theme inspection / WooCommerce's deterministic
`${permalink}#product`). The headless/custom-storefront publish path has no equivalent
step, and `@geoffy/headless`'s `fetchGeoffyProduct` has no option to pass a native `@id`
through either. So on this integration Geoffy's node will stand alone, with no `about`
link — that is the current, correct behaviour of the package, not something to route
around by deleting the other node.
So, on finding an existing `Product` node:
- **Leave it exactly as it is.** Mount `<GeoffyProduct>` alongside it; do not comment out,
delete, or modify the native node or its imports.
- **Never merge the two by hand** — do not copy fields from one into the other, and do not
give Geoffy's script tag the native node's `@id`. Both repeat the mistake described
above in a new shape.
- If the user is uneasy about two `Product` nodes, the correct next step is validating the
rendered page with Google's Rich Results Test or the [schema.org
validator](https://validator.schema.org/) with **both** nodes present — not removing
one on a guess.
- Say plainly, in your handback, that the storefront's own commerce fields (price,
availability, offers) are unaffected and continue to be owned by the native node.
---
## Step 6: The site-wide files
Three files, one line of logic each. Match their source layout.
**Next.js** — `app/llms.txt/route.ts`, and the same for `llms-full.txt` and `agents.md`:
```ts
import { createGeoffyTextRoute } from "@geoffy/headless/next";
export const GET = createGeoffyTextRoute({ siteKey: process.env.GEOFFY_SITE_KEY! }, "llms.txt");
```
**Astro** — `src/pages/llms.txt.ts`, and the same two siblings:
```ts
import { createGeoffyTextEndpoint } from "@geoffy/headless/astro";
export const GET = createGeoffyTextEndpoint(
{ siteKey: import.meta.env.GEOFFY_SITE_KEY },
"llms.txt",
);
```
Write **all three**. They serve one document under three names because three conventions
exist; mounting one leaves agents looking for the others with nothing.
---
## Step 7: The Geoffy namespace
One catch-all route puts every remaining Geoffy surface on their domain — plain-text
product twins, buying guides, and a sitemap of both. Without it that content is served from
Geoffy's domain, and the citations it earns go to Geoffy rather than to them.
**Next.js** — `app/apps/geoffy/[...path]/route.ts`:
```ts
import { createGeoffyProxyRoute } from "@geoffy/headless/next";
export const GET = createGeoffyProxyRoute({ siteKey: process.env.GEOFFY_SITE_KEY! });
```
**Astro** — `src/pages/apps/geoffy/[...path].ts`:
```ts
import { createGeoffyProxyEndpoint } from "@geoffy/headless/astro";
export const prerender = false; // REQUIRED — the build fails without it
export const GET = createGeoffyProxyEndpoint({ siteKey: import.meta.env.GEOFFY_SITE_KEY });
```
Skip this step entirely if they chose to stay static in Step 2.
---
## Step 8: Crawler rules
**Append** Geoffy's block to their `robots.txt`. Never replace their file.
```ts
import { fetchGeoffyText } from "@geoffy/headless";
const YOUR_RULES = `User-Agent: *
Disallow: /admin
`;
const YOUR_GLOBAL_RECORDS = `Host: https://yourdomain.com
Sitemap: https://yourdomain.com/sitemap.xml
`;
export async function GET() {
const geoffyRules = await fetchGeoffyText(
{ siteKey: process.env.GEOFFY_SITE_KEY! },
"robots-rules.txt",
);
// null means Geoffy was unreachable — serve THEIR rules anyway.
const body = [YOUR_RULES, geoffyRules ?? "", YOUR_GLOBAL_RECORDS].filter(Boolean).join("\n");
return new Response(body, {
headers: { "content-type": "text/plain; charset=utf-8" },
});
}
```
Carry across the rules from whatever they had before, verbatim. Then delete the old file —
`app/robots.ts`, or `public/robots.txt`, which wins over any route and would silently keep
serving the old content.
**Order matters.** Geoffy's block contains a `User-agent` group, so it belongs *with their
other groups*, and their global records (`Host:`, `Sitemap:`) go **last**.
---
## Step 9: The IndexNow key
Their Geoffy settings show an IndexNow key. Save it at the site root, named after itself:
```
public/{key}.txt # contents: the key, nothing else
```
The namespace only authorises what sits under it, so without this file every index ping
for a **product page** is discarded — silently, with nothing failing anywhere. Ask them for
the key; do not guess one.
---
## Step 10: Verify what you can
**Every check Geoffy runs against the merchant's own site — ownership verification, the
namespace-mount probe, the post-publish presence check, and the `/ingest` crawl — refuses
anything that is not a public https address.** `localhost`, private/reserved addresses, and
non-default ports are all rejected outright, deliberately, with no override. So none of
those checks can ever pass against `localhost:3000`, whatever port the dev server uses —
this is not a bug to work around, and there is no local-only escape hatch. To test any of
it before deploying, put a public https tunnel in front of the dev server (e.g.
`cloudflared tunnel --url http://localhost:<port>`) and use the tunnel's hostname
everywhere the site's own domain would go — as the site address entered in Geoffy, and as
the value the product page's canonical is built from. If those two disagree, publishing
succeeds but the page is held at `canonical_broken`.
Build first. Then, against their dev server, tunnel, or deployment:
```bash
curl -s <their product URL> | grep -c 'data-geoffy'
curl -si <their domain>/apps/geoffy/sitemap.xml | head -20
curl -s <their domain>/llms.txt | head -20
```
The first is the one that matters: greater than zero means the markup is in the
**server-rendered** HTML. Browser devtools cannot tell you this — they show the page after
JavaScript has run, which is precisely what most AI crawlers never see.
**Zero is expected** if ownership is not yet verified or nothing is published. Say so
plainly rather than treating it as a failure.
**A `curl` against the running dev server can also lie, in the other direction — reporting
stale state as if it were current.** Next.js's dev fetch cache lives in `.next/dev/cache`
(not `.next/cache`, which clearing does nothing to fix) and can hold the proxy's outbound
fetch for up to `revalidateSeconds` (defaults to one hour). So a product that was just
published, or a namespace mount that was just confirmed, can still read as absent for up to
an hour after it changed. Before concluding anything about Geoffy's state from a local
fetch: `rm -rf .next/dev/cache` and restart the dev server, or pass a short
`revalidateSeconds` (for example `10`) to the helpers while you integrate.
---
## Step 11: Hand back
Tell the user exactly what remains, because none of it is yours to do:
1. **Deploy.** Geoffy verifies against the live site, not localhost.
2. **Prove ownership** — serve the token at
`/.well-known/geoffy-site-verification.txt` and run the check in Geoffy.
3. **Generate and publish** in the dashboard. This is the step that costs money.
4. **Watch the state.** Products stay pending until Geoffy fetches the live page and finds
the markup. `code_not_added` means it is not there or renders on the client;
`stale_version` clears itself within the hour; `canonical_broken` means the page does
not name itself canonical.
Link them to <https://geoffy.ai/docs/headless> for the reasoning behind any of it.
---
## Critical rules
- **Never render Geoffy content on the client.** No `next/script` with a client strategy,
no `onMount` injection, no client component. It will look right in a browser and be
invisible to the crawlers this exists for.
- **Never sanitise `widgetHtml`.** Its CSS travels inside it as a `<style>` element; a
sanitiser that strips that leaves the widget unstyled on a live site.
- **Never remove, comment out, or modify a storefront's existing `Product` JSON-LD to make
room for Geoffy's.** Geoffy's node is a supplement and by design never carries
`offers`/`price`/`availability` — the native node is the page's only source for those.
Two `Product` nodes with distinct `@id`s on one page is valid and expected. See *Two
`Product` nodes on one page* above.
- **Never test ownership verification, the namespace probe, or the presence check against
`localhost`.** They refuse any non-public-https address unconditionally — this needs a
tunnel, not a workaround.
- **Never use a `next.config.js` rewrite for the root files.** A catch-all segment answers
`/llms.txt` with the home page and HTTP **200**, which a crawler reads as success.
- **Never omit `export const prerender = false`** on the Astro namespace route. The build
fails outright.
- **Never replace the user's `robots.txt`.** Append, and carry their rules across verbatim.
- **Never change the mount path from `/apps/geoffy`.** Geoffy probes that exact path.
- **Never invent a site key, an IndexNow key, or a product handle.** Ask.
- **Never commit `GEOFFY_REVALIDATE_SECRET`.**
- **Never conclude the integration is broken because nothing renders.** Check whether
ownership is verified and anything is published first. A 404 from Geoffy before
verification, and `published: false` for an unpublished product after it, are documented
states, not defects in your work.
- **Never claim you have finished the setup.** You finished the *code*. Verification and
publishing are the user's, and saying otherwise leaves them thinking it is live when it
is not.
Hand that to your coding agent and it will read your project, ask what it cannot work out, and write every file below. It stops where you have to take over — proving you own the domain, and publishing — because neither can be done from your machine.
Or follow the steps yourself.
Before you start, three things must be true. Your site is connected to Geoffy, you have proved you own your domain, and each product page names itself canonical. The third catches most people — see the product page.
-
Install the package
Section titled “Install the package”Terminal window npm install @geoffy/headlessTerminal window pnpm add @geoffy/headlessTerminal window yarn add @geoffy/headlessTerminal window bun add @geoffy/headlessNode 18 or newer. The package uses global
fetch,AbortControllerandcrypto.subtle, and ships ESM only. -
Set your environment
Section titled “Set your environment”.env # From your site's settings in Geoffy. Public — safe to ship in a build.GEOFFY_SITE_KEY=hs_live_...That is the only required variable. Two optional ones:
.env # Point the whole build at a different Geoffy instance. This is the default,# so leave it unset unless you have been told otherwise.GEOFFY_ORIGIN=https://api.geoffy.ai# Makes a publish reach your pages in seconds instead of within the hour. Step 5.GEOFFY_REVALIDATE_SECRET=...GEOFFY_ORIGINpoints the whole build at a different Geoffy instance without passingoriginto every call below. A value that is not an absolutehttp/httpsURL is ignored and the default is used, so a typo cannot break your page. -
Add the component to your product page
Section titled “Add the component to your product page”app/[lang]/products/[handle]/page.tsx // app/[lang]/products/[handle]/page.tsximport { GeoffyProduct } from "@geoffy/headless/next";export default async function ProductPage({ params }) {const { lang, handle } = await params;return (<><YourProductUI handle={handle} />{/* Renders the widget and the structured data together, right here.Renders nothing when Geoffy has nothing published or is unreachable. */}<GeoffyProductsiteKey={process.env.GEOFFY_SITE_KEY!}handle={handle}canonicalUrl={`https://yourdomain.com/${lang}/products/${handle}`}/></>);}Put it where you want the widget to appear. The structured data is emitted immediately beside it — see where to put the markup for why the pair is not separable in Next.js, and the product page for what
handlemust contain and whatcanonicalUrlbuys you. -
Serve the three root files
Section titled “Serve the three root files”One file each, three lines each.
app/llms.txt/route.ts import { createGeoffyTextRoute } from "@geoffy/headless/next";export const GET = createGeoffyTextRoute({ siteKey: process.env.GEOFFY_SITE_KEY! }, "llms.txt");app/llms-full.txt/route.ts import { createGeoffyTextRoute } from "@geoffy/headless/next";export const GET = createGeoffyTextRoute({ siteKey: process.env.GEOFFY_SITE_KEY! }, "llms-full.txt");app/agents.md/route.ts import { createGeoffyTextRoute } from "@geoffy/headless/next";export const GET = createGeoffyTextRoute({ siteKey: process.env.GEOFFY_SITE_KEY! }, "agents.md");Route handlers rather than a
next.config.jsrewrite — and that matters. -
Mount the revalidate route
Section titled “Mount the revalidate route”Your pages pick up a publish on their own within the revalidate window, one hour by default. This route makes it seconds instead.
app/api/geoffy/revalidate/route.ts import { revalidateTag } from "next/cache";import { createGeoffyRevalidateRoute } from "@geoffy/headless/next";export const POST = createGeoffyRevalidateRoute({secret: process.env.GEOFFY_REVALIDATE_SECRET!,revalidateTag,});Pass
revalidateTagbare. Next 16 gives it a second argument, a cache-life profile, and the package supplies that for you — a profile of your own here makes the first page load after a publish slower, not fresher. Why.The path is fixed. Geoffy calls
https://yourdomain.com/api/geoffy/revalidate— it derives the address from your domain rather than storing one you typed. Put the file anywhere else and it is never called. Copy the secret from your site’s settings in Geoffy intoGEOFFY_REVALIDATE_SECRET.See how updates arrive for what happens if you skip this — it is a real option, not a degraded one.
-
Mount the Geoffy namespace
Section titled “Mount the Geoffy namespace”One catch-all route puts every other Geoffy surface on your own domain.
app/apps/geoffy/[...path]/route.ts import { createGeoffyProxyRoute } from "@geoffy/headless/next";export const GET = createGeoffyProxyRoute({ siteKey: process.env.GEOFFY_SITE_KEY! });Without it that content is still served — from
api.geoffy.ai, not from you — so the citations your buying guides earn go to us instead of to you. Why it is worth a route. -
Merge the crawler rules, and serve your IndexNow key
Section titled “Merge the crawler rules, and serve your IndexNow key”app/robots.txt/route.ts import { fetchGeoffyText } from "@geoffy/headless";const YOUR_RULES = `User-Agent: *Disallow: /admin`;const YOUR_GLOBAL_RECORDS = `Host: https://yourdomain.comSitemap: https://yourdomain.com/sitemap.xml`;export async function GET() {const geoffyRules = await fetchGeoffyText({ siteKey: process.env.GEOFFY_SITE_KEY! },"robots-rules.txt",);// `null` means Geoffy was unreachable. Serve YOUR rules anyway — dropping your own// disallows because our API had a bad minute is the worse failure.const body = [YOUR_RULES, geoffyRules ?? "", YOUR_GLOBAL_RECORDS].filter(Boolean).join("\n");return new Response(body, {headers: {"content-type": "text/plain; charset=utf-8","cache-control": "public, max-age=300, stale-while-revalidate=86400",},});}If you already have an
app/robots.ts, delete it — two files cannot both answer/robots.txt. Where the Geoffy block goes in the file matters; see crawler rules.Then drop your IndexNow key at your site root:
Your site’s settings in Geoffy show your IndexNow key. Save it as
public/{key}.txt— the filename is the key.Skipping this silently discards every product-page index ping. Why.
Check it worked
Section titled “Check it worked”curl -s https://yourdomain.com/your/product/page | grep -c 'data-geoffy'Greater than zero means the markup is in the server-rendered HTML, which is what matters. Viewing it in devtools does not prove this. Full checks, and what each pending reason means, are on checking it worked.