01Build with agentsgeoffy.ai/platforms/magento
Magento and Adobe Commerce logo Works today. Developer integration

Magento and Adobe Commerce. Works today.

Geoffy works with your Magento or Adobe Commerce build today. Every Magento build is different, and Geoffy works with each of them, because the integration fits the build and not the other way round.

Build with agents

Add Geoffy to my storefront: geoffy.ai/docs/SKILL.md
Or paste the whole prompt, for an agent that cannot fetch a URL
Add Geoffy to my storefront
---
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, plus optional AI visit tracking.
---

# 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 something is published, the integration renders NOTHING.** A page with nothing
published answers `published: false`, and the component renders nothing for it. On a site with
Shopify behind the storefront, every Geoffy endpoint also returns 404 until ownership is
verified. (A site with no commerce platform is served before its ownership check.) 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, modify your `robots.txt`, and add one middleware
   if you want AI visit tracking. 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 |
| Existing middleware | Next.js: `proxy.ts` (Next 16) or `middleware.ts`, at the root or in `src/`, and its `matcher`. Astro: `src/middleware.ts` and its `onRequest` |
| Hosting | `vercel.json`, a `.vercel/` directory, or `@astrojs/vercel`. Or self-hosted: a `Dockerfile`, a `next start` start script, `@astrojs/node`, an nginx or Caddy config. It decides whether AI crawler visits can be confirmed, and whether the proxy must forward the visitor's host |
| Installed version | `@geoffy/headless` in `package.json`, if it is already there |
| 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. The same
secret signs AI visit reports, so a yes to either question means they copy it once.

**Q. Track which AI systems read your pages?** *Always ask.* One middleware reports the AI
crawlers that read their own product pages, and the shoppers who arrive from an AI
assistant. Ordinary page views make no network call, and it reads and sets no cookies. It is
optional and nothing else depends on it.
- Recommend yes on Next.js, and on Astro when their product pages render on request. Say why
  from what you found: a site on Vercel gets confirmed crawler visits with nothing to
  configure; a self-hosted site behind a proxy that overwrites `X-Real-IP` gets them with one
  option (Step 10); anywhere else, crawler visits are reported without an address and cannot
  be confirmed against the vendor's ranges.
- On a fully prerendered Astro site, say plainly that prerendered pages never reach
  middleware, so it would count almost nothing. Recommend no unless they switch to
  on-demand rendering.
- If Step 1 found an existing middleware, say you will call Geoffy's from inside it rather
  than replace it.
- It needs `GEOFFY_REVALIDATE_SECRET` from their Geoffy settings, and `@geoffy/headless`
  0.3.1 or later (0.3.2 or later when self-hosted).

**Q. Your site has locales. Which pages should Geoffy publish?** *Only when you found a
locale segment.* Geoffy publishes content per page, in that page's language. Several locales
under one site work: configure a URL pattern per locale and publish the pages the merchant
wants. Recommend passing both `locale` (the locale the route renders) and `canonicalUrl`
(this page's own canonical). Each published page renders its own content; an unpublished
page renders nothing.

Two pages may share a handle when `canonicalUrl` is passed. This needs `@geoffy/headless`
0.3.5 or later. Without `canonicalUrl`, the page published last for that handle answers.

After mounting, verify each published locale directly with `curl`: each page must contain
its own `data-geoffy` markup and the expected structured data. If a locale has nothing
published, confirm it contains neither Geoffy's widget nor Geoffy's structured data.
The `canonicalUrl` must differ per locale and name that page's own canonical. Pass `locale`
to check that the content's language matches the page too.

**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.

If the project already depends on `@geoffy/headless`, check the version before you write
code against it, and upgrade when it is too old for what they chose:

| Needs | Version |
|---|---|
| `locale` on the product page | 0.2.0 or later |
| AI visit tracking | 0.3.1 or later. 0.3.0 has the middleware but sends one visit per call, so a crawler sweep is mostly dropped |
| AI visit tracking on a self-hosted server (`next start`, Astro's Node adapter) | 0.3.2 or later. Earlier versions report every visit on `localhost` |
| The canonical `Link` header on each plain-text twin, and Geoffy's `X-Robots-Tag`, through the namespace route | 0.3.3 or later. Earlier versions pass neither header on |
| A site whose Structured data setting is Never, or whose pages carry their own node; and the `GeoffyContent` names | 0.3.4 or later. Earlier versions render nothing at all, widget included, for a page with no Geoffy structured data |
| Two pages that share a handle (an untranslated slug in two locales, or two categories that end in the same word) | 0.3.5 or later |

---

## 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.

If they said yes to the revalidate route (Next.js) or to AI visit tracking (either
framework):

```bash
GEOFFY_REVALIDATE_SECRET=...
```

A missing `GEOFFY_SITE_KEY` at runtime does not fail a page. The package makes no request,
renders nothing, and logs `@geoffy/headless: GEOFFY_SITE_KEY is not set` once. So an empty
render with that line in the server log is a missing variable, not a broken integration.

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
```

The public docs list it as optional and say to leave it unset. 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.

**The widget may render alone.** When the site owns its structured data (its Structured data
setting in Geoffy is Never, or the page already carries its own node of the same type), Geoffy
sends none: the Next component renders the widget only, and the Astro helper returns an empty
`jsonLdScript`. That is correct. Do not add structured data by hand to make up for it.

**Pages that are not products** (a university programme, a service) use the same code.
`GeoffyContent`, `getGeoffyContentMarkup` and `fetchGeoffyContent` are the same exports under
names for such a page; use them when the page is not a product, so the code reads truthfully.
On such a site, the "Two `Product` nodes" section below does not apply: Geoffy adds a node of
the page's own type only where the page has none.

**Several languages with the same slug.** Geoffy publishes content per page, in the language
the page declares. A site that serves `/de/programmes/data-science` and
`/programmes/data-science` has two pages with one handle, `data-science`. Each page passes its
own values, and needs `@geoffy/headless` 0.3.5 or later:

```tsx
// app/[lang]/programmes/[slug]/page.tsx: one route, every language
<GeoffyContent
  siteKey={process.env.GEOFFY_SITE_KEY!}
  handle={slug}                              // the same in every language here
  locale={lang}                              // "de", "en"
  canonicalUrl={canonicalFor(lang, slug)}    // THIS page's own canonical, different per language
/>
```

```astro
---
// src/pages/[lang]/programmes/[slug].astro
const geoffy = await getGeoffyContentMarkup(
  { siteKey: import.meta.env.GEOFFY_SITE_KEY },
  slug!,
  { canonicalUrl: canonical, locale: lang },  // this page's own canonical
);
---
```

`canonicalUrl` selects this page's content; without it, the page published last for the handle
answers on every language. Use the canonical the site already builds for that page. Never pass
one canonical for all languages: then every language claims to be one page. In the dashboard
the user gives one URL pattern per language (`/de/programmes/{handle}` and
`/programmes/{handle}`). The worked example is at
<https://geoffy.ai/docs/headless/nextjs/recipes/several-languages>.

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 12. 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 });
```

Where a page's plain-text twin lives:

| Site | Twin address | Example |
|---|---|---|
| No commerce platform | the page's own path under `/apps/geoffy/llm/` | `/apps/geoffy/llm/de/programmes/data-science.md` for the page `/de/programmes/data-science` |
| Shopify behind the storefront | the product handle | `/apps/geoffy/llm/blue-widget.md` |

So two pages that share a handle each have their own twin. The Geoffy sitemap at
`/apps/geoffy/sitemap.xml` lists every twin at its address.

From 0.3.3, each plain-text twin answers with a `Link: <https://…>; rel="canonical"` header. It
names the twin's HTML page on their domain. Geoffy's `X-Robots-Tag` also comes through, on a 200
and on a 404. Do not add a CDN rule or a middleware that removes either header.

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: AI visit tracking (only if they said yes in Step 2)

**Next.js**: `proxy.ts` on Next 16, `middleware.ts` with `export const middleware` on
earlier versions, beside `app/` (inside `src/` if the project uses it):

```ts
import { createGeoffyAiVisitMiddleware } from "@geoffy/headless/next-middleware";

export const proxy = createGeoffyAiVisitMiddleware({ siteKey: process.env.GEOFFY_SITE_KEY! });
```

It returns nothing, which Next reads as "carry on". If they already have a middleware, call
Geoffy's from inside it and keep returning their response:

```ts
import type { NextFetchEvent, NextRequest } from "next/server";
import { createGeoffyAiVisitMiddleware } from "@geoffy/headless/next-middleware";

const trackAiVisits = createGeoffyAiVisitMiddleware({ siteKey: process.env.GEOFFY_SITE_KEY! });

export function proxy(request: NextRequest, event: NextFetchEvent) {
  trackAiVisits(request, event);
  return theirExistingLogic(request);
}
```

Keep their `matcher` covering the product pages. Do not add exclusions for `/_next/` or
static files: the middleware already skips them.

**Astro**: `src/middleware.ts`:

```ts
import { createGeoffyAiVisitMiddleware } from "@geoffy/headless/astro-middleware";

export const onRequest = createGeoffyAiVisitMiddleware({
  siteKey: import.meta.env.GEOFFY_SITE_KEY,
  secret: import.meta.env.GEOFFY_REVALIDATE_SECRET,
});
```

Pass `secret` explicitly on Astro: the package's own fallback reads `process.env`, which does
not always carry `.env` values there. If they already have an `onRequest`, combine the two
with `sequence()` from `astro:middleware`.

**The visitor's address.** Leave `clientIp` unset unless the user tells you their platform
writes a client address that visitors cannot set. On Vercel the default already reads the
address Vercel vouches for. **Never** build `clientIp` from `X-Forwarded-For`: its first
entry is whatever the client sent, and trusting it lets anyone pose as a crawler. On Astro,
`context.clientAddress` is safe only when the adapter takes it from the socket or the
platform's own header, so do not pass it without checking.

**Self-hosted (not Vercel).** When the site runs on its own server behind a reverse proxy,
`next start` hands middleware `http://localhost:<port>/...` whatever host the visitor asked for,
and Astro's Node adapter sees whatever `Host` the proxy sends. From 0.3.2 the middleware then
takes the host from `X-Forwarded-Host` (else `Host`) and the protocol from `X-Forwarded-Proto`,
so the proxy must forward them. For nginx:

```nginx
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
```

The proxy config is usually outside the app repo. If it is not in the project, give the user
these lines instead of guessing where it lives. For confirmed crawler visits, and only when the
user confirms every request reaches the server through that proxy (the app port is not
reachable directly), pass the address the proxy wrote:

```ts
clientIp: (request) => request.headers.get("x-real-ip"),
```

The package never reads `X-Real-IP` by default: without the proxy in front, a client sets it.

Say in your handback that it sends nothing for ordinary page views, sends no user agent or
address for shoppers arriving from an assistant, and reads and sets no cookies.

---

## Step 11: 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
# no commerce platform: the twin lives at the page's own path
curl -s -D - -o /dev/null <their domain>/apps/geoffy/llm/<a published page path>.md | grep -i '^link'
# Shopify behind the storefront: the twin lives at the handle
curl -s -D - -o /dev/null <their domain>/apps/geoffy/llm/<a published handle>.md | grep -i '^link'
```

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 nothing is published for that page, or, with Shopify behind the
storefront, if ownership is not yet verified. Say so plainly rather than treating it as a
failure.

On a site in several languages, run the first command on each language's page. Each published
page must count more than zero on its own. Zero on a published page means the package is older
than 0.3.5, or the page does not pass its own `canonicalUrl`.

The last two print `link: <…the page's own URL…>; rel="canonical"` when the namespace route is
mounted on 0.3.3 or later. No line means an older package, or something in front of the route
removes the header. An old buying-guide address answers `301` with a `Location` under
`/apps/geoffy/`, never one on Geoffy's domain.

**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 12: 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. A confirmed
   `/apps/geoffy` mount also proves it. With no commerce platform this blocks nothing and
   reserves the domain; with Shopify behind the storefront nothing is served until it passes.
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.
5. **If they added AI visit tracking, set `GEOFFY_REVALIDATE_SECRET` in the production
   environment too.** Without it the middleware sends nothing, and nothing reports that.
   On a self-hosted site, their proxy must also forward `Host`, `X-Forwarded-Host` and
   `X-Forwarded-Proto` (and overwrite `X-Real-IP` if you passed `clientIp`), or visits are
   reported on `localhost`.

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 read `X-Forwarded-For` for the AI visit middleware's `clientIp`.** Its first entry
  is the client's own claim. Read `X-Real-IP` only when the user confirms a proxy overwrites
  it and the app port is not reachable directly.
- **Never conclude the integration is broken because nothing renders.** Check whether
  anything is published for that page first, and, with Shopify behind the storefront, whether
  ownership is verified. `published: false` for an unpublished page, and a 404 from Geoffy
  before a Shopify store's own site is verified, are documented states, not defects in your
  work.
- **Never pass one canonical for every language.** Each page passes its own `canonicalUrl`.
  It is what selects that page's content when two languages share a handle.
- **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.

Last updated

02How it connects00:00:06:00

How it connects

How Geoffy connects

Geoffy reads your catalogue from your live product pages. Your storefront prints the Geoffy block and structured data from the Geoffy API, server-side. That fits any Magento build.

03Who does the work00:00:12:00

Who does the work

Your development team, once.

Your development team does the integration, with ongoing support from us for your build. There is no plugin to install: a developer prints Geoffy's content into the server-rendered page once, and Geoffy keeps it current after that.

04What you get after the setup00:00:18:00

What you get after the setup

The same product as a Shopify store.

Your pages

The verified block on every product page, matching structured data, llms.txt, llms-full.txt, agents.md and buying guides on your own domain.

AI results

The weekly AI test across ChatGPT, Perplexity, Gemini and Claude, with cited sources, the pathway split and your rank against competitors.

Other websites

Citation briefs and outreach packs for the sites the AI cites.

IndexNow on every publish

Search engines are pinged every time Geoffy publishes.

05What is true on any build00:00:24:00

What is true on any build

Geoffy fits your build, not the other way round.

Your build as it is

No replatform, no new theme.

Server-rendered

Most AI crawlers do not run JavaScript, so a script tag is not enough. Your developer prints Geoffy's content into the HTML.

A coding agent can do it

Give it one line: Add Geoffy to my storefront: geoffy.ai/docs/SKILL.md. The agent writes the code; a person verifies the domain and publishes.

Your own domain

Content is served from your domain, so the citations go to you.

AI visit tracking, if you want it

See which AI crawlers read your pages and which shoppers arrive from an AI assistant. No cookies.

Fast to update

Publish reaches the page in seconds, or within an hour without the revalidate route.

Your Product schema stays yours

Geoffy never replaces the store's own Product schema. It adds to it, so price and stock stay with the store.

06What it costs00:00:30:00

What it costs

The same plans and prices as Shopify and WordPress: £199, £499, £1,499 a month. No setup fee for the integration.

07FAQ00:00:36:00

FAQ

Magento and Adobe Commerce questions

Short answers. Ask us if yours is not here.

Is there a Magento extension?

No. Your development team integrates Geoffy from the docs, which is why it works on any Magento build.

Does it work on Adobe Commerce?

Yes, the same way as Magento, today.

Does it cost more than Shopify?

No. The plans and prices are the same on every platform, with no setup fee.

Will it change my product data?

No. Geoffy never writes into your descriptions or standard fields, and it adds to your Product schema rather than replacing it.

08Next step00:00:42:00

Next step

Geoffy works with yours today.

Your developers integrate Geoffy from the docs. We help, and we never say no to a build.

Native apps for Shopify and WordPress. Every other platform integrates from geoffy.ai/docs.

Already have an account? Sign in