New:Microsoft Teams Notifications Are Now Available in Socket.Learn more →
Get Started

@agentshop/seo

Package Overview
Dependencies
Maintainers
2
Versions
23
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@agentshop/seo

Server-rendered SEO for Shopify headless storefronts — JSON-LD, meta, Open Graph, and sitemaps for Next.js, Remix, and Hydrogen.

latest
npmnpm
Version
0.4.0
Version published
Weekly downloads
50
-51.46%
Maintainers
2
Weekly downloads
 
Created
Source

@agentshop/seo

Server-rendered SEO for Shopify headless storefronts — schema.org JSON-LD, meta title/description, canonical, Open Graph/Twitter, and a product sitemap — fetched from your AgentShop SEO endpoint and injected into the initial HTML so AI crawlers (GPTBot, ClaudeBot, PerplexityBot) and search engines can read it.

One import per framework; the framework-specific gotchas are handled for you.

Install

npm i @agentshop/seo
# .env
AGENTSHOP_API_KEY=<your store's API key from Dashboard → Settings → API Keys>
AGENTSHOP_SEO_URL=https://api.useagentshop.com/api/v1/headless-seo

Your API key (ask_…) is generated per store in the AgentShop dashboard under Settings → API Keys. It identifies exactly one store, so the endpoints know what to serve without you passing a store id anywhere.

The key is a secret. Keep it server-side only — never in a client bundle or a NEXT_PUBLIC_* var. Every helper below runs on the server, so the key never reaches the browser. Rotating a key in Settings keeps the old one working for 24 hours, so you can rotate and redeploy without a gap in coverage.

Next.js — App Router

// app/products/[handle]/page.tsx
import { generateProductMetadata, ProductJsonLd } from "@agentshop/seo/next";

export const generateMetadata = generateProductMetadata;

export default async function Page({ params }: { params: Promise<{ handle: string }> }) {
  const { handle } = await params;
  return (
    <>
      <ProductJsonLd handle={handle} />
      {/* ...your product page... */}
    </>
  );
}
// app/sitemap.xml/route.ts
import { productSitemapRoute } from "@agentshop/seo/next";
export const { GET } = productSitemapRoute();

Next.js — Pages Router

// pages/products/[handle].tsx
import { getProductSeoProps, ProductHead } from "@agentshop/seo/next/pages";

export const getServerSideProps = getProductSeoProps;

export default function Product({ seo }: { seo: any }) {
  return (
    <>
      <ProductHead seo={seo} />
      {/* ...your product page... */}
    </>
  );
}
// pages/sitemap.xml.ts
import { productSitemapHandler } from "@agentshop/seo/next/pages";
export default productSitemapHandler();

Storefront analytics (Next.js App Router)

Render once in your root layout. Nothing else to configure — the same AGENTSHOP_API_KEY you already set is enough.

// app/layout.tsx
import { AgentShopPixel } from "@agentshop/seo/next";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html>
      <body>
        {children}
        <AgentShopPixel />
      </body>
    </html>
  );
}

It loads the script through Next's <Script strategy="afterInteractive">, the strategy Next documents for analytics.

Why this and not a pasted <script> tag. The pixel token identifies your store to our ingest endpoint, and it can change — reconnecting the app or uninstalling and reinstalling it issues a new one. A token pasted into your codebase is frozen at the moment you deployed: the day it changes, every pixel request returns 401 and attribution silently stops until you notice and redeploy. This component asks for the URL at render time (revalidated every 5 minutes), so a changed token is picked up on its own and no analytics credential ever lands in your source.

On other frameworks, use the core helper and render the tag yourself:

import { fetchPixelConfig } from "@agentshop/seo";

const pixel = await fetchPixelConfig(); // { scriptUrl, token, eventsUrl, shopDomain } | null

Reporting Shopify's own storefront events instead

If your storefront already runs Shopify's analytics bus — <Analytics.Provider> in Hydrogen — you can skip our script and forward that bus instead.

On the framework-agnostic path. ShopifyScripts and window.Shopify.analytics.addDestination() ship only in Shopify's developer preview; the released @shopify/hydrogen-react exports neither, so createAgentShopDestination is usable only if you are on that preview. The supported interface today is useAnalytics() — use subscribeAgentShop below. You get Shopify's typed event payloads, Shopify's consent gating, and Shopify's visitor identity rather than ours.

import { createAgentShopDestination } from "@agentshop/seo/analytics";
import { getTrackingValues } from "@shopify/hydrogen-react";

window.Shopify.analytics.addDestination(
  createAgentShopDestination({
    ...pixel, // { token, shopDomain, eventsUrl } from fetchPixelConfig()
    getIdentity: () => {
      const { uniqueToken, visitToken } = getTrackingValues();
      return { clientId: uniqueToken, sessionId: visitToken };
    },
  }),
);

On Hydrogen, hand the bus itself to subscribeAgentShop — same forwarding, same events:

import { useEffect } from "react";
import { useAnalytics } from "@shopify/hydrogen";
import { getTrackingValues } from "@shopify/hydrogen-react";
import { subscribeAgentShop } from "@agentshop/seo/analytics";
import type { PixelConfig } from "@agentshop/seo";

// pixel: the result of fetchPixelConfig(context.env) in your root loader — it
// can only run server-side (the API key is a secret), so it comes in as a
// prop rather than being fetched here. null when the store has no pixel
// configured; handled below before the destination is ever registered.
export function AgentShopAnalytics({ pixel }: { pixel: PixelConfig | null }) {
  const { subscribe, register } = useAnalytics();
  const { ready } = register("AgentShop");

  useEffect(() => {
    // Subscribe BEFORE ready(): Hydrogen's AnalyticsProvider holds the event
    // queue (including the initial page_viewed) until every registered
    // consumer has called ready() — flipping the order flushes the queue
    // before this subscriber is attached and the entry page view is lost.
    if (!pixel) {
      ready(); // still release the queue — nothing to forward for this store
      return;
    }
    const getIdentity = () => {
      const { uniqueToken, visitToken } = getTrackingValues();
      return { clientId: uniqueToken, sessionId: visitToken };
    };
    const teardown = subscribeAgentShop({ subscribe }, { ...pixel, getIdentity });
    ready();
    return teardown;
  }, [subscribe, ready, pixel]);

  return null;
}

Note that Hydrogen's subscribe returns void, so the teardown subscribeAgentShop hands back is a no-op on that bus; Shopify provides no way to unsubscribe there. It is not a leak — Hydrogen keys its subscriber map on callback.toString(), so re-subscribing replaces rather than duplicates. On the framework-agnostic Hydrogen SDK the teardown is real, so call it on unmount either way.

Consent. Every event is gated on Shopify's own answer — window.Shopify.customerPrivacy.analyticsProcessingAllowed(), the same call Hydrogen's <Analytics.Provider> uses for its canTrack prop. It already encodes what varies by region (opt-in before tracking in some, opt-out in others), so this package never re-derives consent from a country or a cookie.

The check runs per event, because consent can be granted or withdrawn mid-session. If the Customer Privacy API isn't loaded there is no answer, and nothing is sent — an unknown is not a yes. Load it with useCustomerPrivacy() on Hydrogen, or Shopify's consent-tracking-api.js on any other headless storefront, calling setTrackingConsent with headlessStorefront: true. Pass canTrack if your storefront establishes consent some other way.

One prerequisite. uniqueToken and visitToken come from Storefront API Server-Timing headers, which Shopify only exposes to a same-origin request at /api/<version>/graphql.json. If your storefront calls the Storefront API directly from the browser on *.myshopify.com, both are empty and this destination sends nothing — deliberately, because an event stamped with an identifier we invented is worse than a missing one. Shopify stopped setting _shopify_y / _shopify_s on January 1 2026 and deprecated them on April 30 2026; the proxy is the supported replacement.

Checkout events are not part of this. Shopify hosts your checkout, so our app's Web Pixel already reports checkout and order events for headless stores — there is nothing for you to instrument there.

How this differs from the served <script>. The served script keeps its own cross-pageview memory of how a visitor arrived (first UTM/referrer seen this session); this SDK doesn't — it reads UTMs and document.referrer fresh on each page, so an event fired several pages after the landing page may carry neither if the visitor navigated internally since. Delivery differs too: sendBeacon has no retry and a roughly 64 KiB payload cap per call (the served script batches and retries server visibly). Both are fine for the events this destination sends; keep them in mind if you're diagnosing a gap against the served script's numbers.

AI crawler visibility

GPTBot, ClaudeBot and PerplexityBot never run JavaScript, so no pixel — ours or anyone's — can see them. Your server is the only place those fetches exist.

Next.js

// proxy.ts  (Next 16+. On Next 15 and below the file is middleware.ts and the
//            export is named `middleware` — the wrapper is the same.)
import { withAgentShopCrawlerCapture } from "@agentshop/seo/next/proxy";

export const proxy = withAgentShopCrawlerCapture();

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};

Reported URLs are stripped to origin + pathname — query strings never leave your server. If any of your routes carry secrets in the path itself (/reset/<token>, signed downloads, invite links), exclude them here too — Next documents negative matching for exactly this (proxy docs):

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico|account|reset).*)"],
};

Already have one? Pass it in — yours runs first, its response is returned untouched:

export const proxy = withAgentShopCrawlerCapture(async (request) => {
  return NextResponse.next();
});

Reporting is deferred with event.waitUntil, so it never delays a response and isn't lost when a serverless invocation freezes.

Cloudflare

If your storefront sits behind Cloudflare, the proxy above only sees requests your app renders — cached edge responses never reach it. A Worker sees all of them:

// src/index.js — deploy with Wrangler
import { reportIfCrawler } from "@agentshop/seo";

export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    // The whole capture recipe — isbot gate, origin+pathname stripping (no
    // query strings, no fragments), referrer handling — in one call. The
    // Worker performed the fetch itself, so it has a real status to report.
    ctx.waitUntil(
      reportIfCrawler(request, {
        apiKey: env.AGENTSHOP_API_KEY,
        baseUrl: env.AGENTSHOP_SEO_URL,
        statusCode: response.status,
      }),
    );
    return response;
  },
};

Set AGENTSHOP_API_KEY as a Worker secret (wrangler secret put), route it at *yourdomain.com/*, and bind it to the zone serving your storefront.

Any other host

Both of the above are the same HTTP call, so any server or edge runtime works:

POST /api/v1/crawler-hits
Authorization: Bearer <your ask_… key>

{ "url": "https://…", "method": "GET", "statusCode": 200, "userAgent": "…", "referrer": "…" }

No IP address is collected or accepted. Which crawler a request came from is decided server-side, so no bot list ships to your edge and none of it goes stale in your deployment.

Remix

// app/routes/products.$handle.tsx
import { json } from "@remix-run/node";
import { loadProductSeo, agentshopProductMeta } from "@agentshop/seo/remix";

export async function loader({ params }) {
  return json(await loadProductSeo(params.handle));
}
export const meta = agentshopProductMeta;

Hydrogen

// app/routes/products.$handle.tsx
import { json } from "@shopify/remix-oxygen";
import { loadProductSeo, agentshopProductMeta } from "@agentshop/seo/hydrogen";

// Oxygen exposes env on context.env — set AGENTSHOP_API_KEY + AGENTSHOP_SEO_URL there.
export async function loader({ context, params }) {
  return json(await loadProductSeo(context, params.handle));
}
export const meta = agentshopProductMeta;

Configuration

Every helper takes an optional config that overrides the env defaults. For the App Router generateMetadata, use the factory so config isn't confused with Next's (props, parent) call signature:

export const generateMetadata = createProductMetadata({ timeoutMs: 5000 });
// other helpers: loadProductSeo(handle, { baseUrl, apiKey }), ProductJsonLd config prop, etc.
  • apiKey — defaults to process.env.AGENTSHOP_API_KEY (Hydrogen: context.env.AGENTSHOP_API_KEY).
  • baseUrl — defaults to process.env.AGENTSHOP_SEO_URL (Hydrogen: context.env.AGENTSHOP_SEO_URL).
  • timeoutMs — request timeout, default 3000. Fetches never throw — on any failure (missing config, bad key, timeout, network) they return null and render no tags.

Because failures degrade to "no tags" rather than an error, a bad credential looks the same as no credential. If your tags don't appear, check in this order:

  • AGENTSHOP_SEO_URL unset — the only case that logs a warning (Missing base URL, once).
  • AGENTSHOP_API_KEY unset, wrong, or rotated out — the endpoint 401s and the helper returns null silently. Confirm the key in Settings → API Keys matches your env.
  • Timeout — timeoutMs defaults to 3000; a cold backend can exceed it. Raise it and retry.

Compatibility

Versions
React18, 19
Next.js14, 15, 16 (async params handled)
Remixv2
HydrogenOxygen (context.env)
RuntimeNode ≥18, edge/workerd

Verify it works

Load a product page, then View Source (not the devtools DOM) and confirm the <script type="application/ld+json">, <title>, <link rel="canonical">, and og:* tags are in the raw HTML. Paste the JSON-LD into https://validator.schema.org/.

Notes

  • Render each JSON-LD component once, and only if your storefront doesn't already emit its own. Two Product nodes describing the same page leave Google choosing between them — it may use the wrong one or ignore both, and mismatches show up as Search Console warnings. These are server components with no DOM to inspect, so the package can't detect an existing block for you.
  • Remix / Hydrogen: the framework serializes script:ld+json itself, with no < escaping. If a bundle's JSON-LD contains any <, this package omits the descriptor (and logs a warning once per page) rather than emit markup that could break out of the <script> tag — you'll see the rest of the meta tags but no structured data for that page. The check is broader than </ on purpose: <!--<script> escapes the element through the HTML tokenizer's escape states without containing </ at all. That means a bare < in a title or description (Widget < 5kg) also suppresses structured data — write it as &lt; or remove it to restore. The Next adapters escape instead, via serializeJsonLd, and are unaffected.
  • Calling the REST API directly? bundle.jsonLd is returned as a plain object, unescaped. If you render it yourself, use the exported serializeJsonLd — JSON.stringify alone lets a </script> in any product field inject markup into your storefront:
    import { serializeJsonLd } from "@agentshop/seo";
    <script type="application/ld+json"
      dangerouslySetInnerHTML={{ __html: serializeJsonLd(bundle.jsonLd) }} />
    
  • App Router's generateProductMetadata omits og:type — Next's typed Metadata.openGraph rejects "product". The Pages Router / Remix / Hydrogen adapters emit og:type=product (raw meta).
  • React 18 (Next 14): <ProductJsonLd> is an async server component, which React 18's types flag as "not a valid JSX element" (it works at runtime). If your editor complains, render it inside your async page or add {/* @ts-expect-error async server component */} above it. React 19 (Next 15/16) has no such issue.
  • ESM-only. Zero runtime dependencies (uses the global fetch).

Keywords

seo

FAQs

Package last updated on 10 Aug 2026

Related posts