
Security News
upm Launches as a Fast, Tiny Package Manager Written in TypeScript
upm uses Node.js to deliver fast npm installs in about 250 KB, with a JavaScript API and security defaults.
@agentshop/seo
Advanced tools
Server-rendered SEO for Shopify headless storefronts — JSON-LD, meta, Open Graph, and sitemaps for Next.js, Remix, and Hydrogen.
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.
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.
// 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();
// 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();
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
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.
ShopifyScriptsandwindow.Shopify.analytics.addDestination()ship only in Shopify's developer preview; the released@shopify/hydrogen-reactexports neither, socreateAgentShopDestinationis usable only if you are on that preview. The supported interface today isuseAnalytics()— usesubscribeAgentShopbelow. 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.
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.
// 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.
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.
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.
// 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;
// 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;
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.timeoutMs defaults to 3000; a cold backend can exceed it. Raise it and retry.| Versions | |
|---|---|
| React | 18, 19 |
| Next.js | 14, 15, 16 (async params handled) |
| Remix | v2 |
| Hydrogen | Oxygen (context.env) |
| Runtime | Node ≥18, edge/workerd |
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/.
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.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 < or remove it to restore. The Next adapters escape
instead, via serializeJsonLd, and are unaffected.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) }} />
generateProductMetadata omits og:type — Next's typed Metadata.openGraph
rejects "product". The Pages Router / Remix / Hydrogen adapters emit og:type=product (raw meta).<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.fetch).FAQs
Server-rendered SEO for Shopify headless storefronts — JSON-LD, meta, Open Graph, and sitemaps for Next.js, Remix, and Hydrogen.
The npm package @agentshop/seo receives a total of 46 weekly downloads. As such, @agentshop/seo popularity was classified as not popular.
We found that @agentshop/seo demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 2 open source maintainers collaborating on the project.

Security News
upm uses Node.js to deliver fast npm installs in about 250 KB, with a JavaScript API and security defaults.

Company News
Socket is joining the OpenJS Security Stewardship Program to fund Node.js vulnerability research, maintainer remediation, and security releases.

Security News
Two compromised GitHub Actions were re-enabled with malicious tags intact, exposing thousands of downstream repositories to Mini Shai-Hulud.