@jrcodex/seo-kit
v0.6.1
Published
Render posts from the JRCodex SEO engine inside your own site: typed client, Next/vinext metadata helpers, media proxy.
Maintainers
Readme
@jrcodex/seo-kit
Render posts from the JRCodex SEO engine inside your own site, in your own design. The engine writes and your client approves; this package fetches the result for your pages to lay out.
npm install @jrcodex/seo-kitWire it up (Cloudflare Worker in the same account)
// wrangler.jsonc
"services": [{ "binding": "SEO", "service": "seo-engine", "entrypoint": "Sites" }]wrangler secret put SEO_SITE_KEY # the sk_... key from "Connect a website"// lib/seo.ts
import { env } from "cloudflare:workers";
import { rpc } from "@jrcodex/seo-kit";
export const seo = () => rpc(env.SEO, env.SEO_SITE_KEY);A post page (Next.js App Router / vinext)
// app/[locale]/blog/[slug]/page.tsx
import { notFound } from "next/navigation";
import { postBodyProps, postJsonLd, postMetadata } from "@jrcodex/seo-kit/next";
import { seo } from "@/lib/seo";
export const revalidate = 300;
export async function generateMetadata({ params }) {
const { locale, slug } = await params;
const post = await seo().post(locale, slug);
return post ? postMetadata(post) : {};
}
export default async function Page({ params }) {
const { locale, slug } = await params;
const post = await seo().post(locale, slug);
if (!post) notFound();
return (
<YourArticleLayout title={post.title} hero={post.hero} category={post.category}>
<script type="application/ld+json" dangerouslySetInnerHTML={postJsonLd(post)} />
<div className="prose" {...postBodyProps(post.html)} />
</YourArticleLayout>
);
}That is the quickest version: the body as one block of filtered HTML. The section below is the one that makes each post look laid out by hand.
Say what your site is
The engine adapts to what a site has, so declare it once, in code, and tell the engine on cold start. The dashboard then shows only the modules you have, in your business's words ("Clients" for a service business, "Customers" for a shop), and every post is composed with what you can render.
// lib/seo.ts
import type { Manifest } from "@jrcodex/seo-kit";
export const manifest: Manifest = {
version: 1,
kind: "service", // service | shop
has: { blog: true, intake: true }, // modules this site has
renders: { aside: false, placements: ["wide", "inset-right"] }, // what your components can show
locales: ["en", "es"],
intake: { fields: ["name", "phone", "email", "message"] }, // the only fields an inquiry may carry
};// worker/index.ts
import { announceOnce } from "@jrcodex/seo-kit";
ctx.waitUntil(announceOnce(seo(), manifest)); // one call per isolate, retried if it failsName features in your own words. portfolio, projects and our_work are
the gallery, quote_form and contact_form the intake form, and your owner
sees your word ("Portfolio"). A word the engine does not recognise is left
out, never refused, and a person decides what it is. announce() returns
unrecognized saying what became of each. A malformed manifest (a value that
is not true or false, say) is still refused with a ManifestError naming the
part. renders has the same
shape compose() takes: compose(post, { supports: manifest.renders }).
Your owner's dashboard, in your site's look
Send your site's theme (platform-theme contract v1) as theme in the
manifest. The engine checks every value and every contrast pair
(contract v1): an accepted theme styles your owner's dashboard, and one that
fails is dropped - the dashboard keeps the platform's look - while the rest of
the manifest is kept. announce() returns theme: { status: "accepted" } or
{ status: "dropped", reason }.
Commit the theme file to your source tree - lib/dashboard-theme.json, say -
and regenerate it with a script. Do NOT import it from dist/: a clean
checkout has no dist/, so tsc fails (TS2307) before anything is built, and
vinext build empties dist/ before it bundles, so the import is unresolved
anyway (found by montano-btm, 2026-09-26).
import type { DashboardTheme, Manifest } from "@jrcodex/seo-kit";
import theme from "./dashboard-theme.json"; // lib/, committed; `npm run dashboard-theme` regenerates it
// A JSON import widens `version: 1` to number; the engine checks the real thing.
export const manifest = { version: 1, kind: "service", has: { blog: true }, theme: theme as DashboardTheme } satisfies Manifest;Add a test that regenerates the theme and fails if it differs from the committed file, so the file never drifts from your site's CSS.
Take an inquiry
Keep your own form, in your own design. Verify your bot check, then hand the submission to the engine; it keeps only the fields your manifest declared and shows the business a new inquiry first thing on their dashboard.
// worker/index.ts, or a route handler
import { InquiryRefused } from "@jrcodex/seo-kit";
if (!(await turnstileOk(request))) return new Response("Please try the check again", { status: 400 });
const form = await request.formData();
try {
await seo().inquire({
locale: "en",
page: "/contact",
fields: Object.fromEntries(form), // undeclared fields are dropped by the engine
});
return Response.redirect("/contact/thanks", 303);
} catch (err) {
if (err instanceof InquiryRefused) return new Response(err.message, { status: err.retryAfterSeconds ? 429 : 400 });
throw err;
}Each site may send 60 an hour; above that the engine answers "try again in a
minute" (retryAfterSeconds) rather than dropping the person. Inquiries are
personal data: the engine deletes them a year after they arrive.
instanceof InquiryRefused works from 0.4.1 on: Workers RPC keeps an error's
name but not its class, so the kit rebuilds its own class from the name
(SiteKeyError and ManifestError too). retryAfterSeconds crosses the
binding only with enhanced error serialization: give the site's Worker a
compatibility_date of 2026-04-21 or later, or the
enhanced_error_serialization compatibility flag.
A picture from a recipe (0.6.0)
A recipe is a tool the business keeps on the engine: Montano's visualizer takes a photo of a room and a tile swatch and makes the room with that tile. Your site keeps its own form in its own design. A route on your Worker hands the engine the photos and gets back a run:
// app/api/visualize/route.ts
import { RecipeRefused, runRecipe } from "@jrcodex/seo-kit";
import { seo } from "@/lib/seo";
export async function POST(request: Request) {
const form = await request.formData();
if (!(await turnstileOk(form, request))) return Response.json({ error: "Please try the check again." }, { status: 400 });
if (!(await visitorAllowed(request))) return Response.json({ error: "Please wait a little and try again." }, { status: 429 });
const room = form.get("room");
const tile = form.get("tile");
if (!(room instanceof File) || !(tile instanceof File)) return Response.json({ error: "Add both photos." }, { status: 400 });
try {
const run = await runRecipe(seo(), "visualizer", {
room: { image: await room.arrayBuffer() },
tile: { image: await tile.arrayBuffer() },
});
return Response.json(run); // a RecipeRun: done, failed, or still running after 90 s
} catch (err) {
if (err instanceof RecipeRefused) return Response.json({ error: err.message }, { status: err.retryAfterSeconds ? 429 : 400 });
throw err;
}
}and on the page:
{run.status === "done" && run.result?.kind === "image" && (
<figure>
<img src={run.result.src} alt="Your room with the tile you chose" />
<figcaption>{run.disclaimer}</figcaption>
</figure>
)}
{run.status === "failed" && <p>{run.error}</p>}runRecipe() starts the run and polls it every 1.5 s for up to 90 s. To
answer at once instead, call seo().startRecipe(...), return the run (it says
"running"), and let the page poll a second route that returns
seo().recipeRun(id).
What is yours and what is the engine's:
- Turnstile and per-visitor limits are your site's job, in front of the
call. The engine never sees a visitor, only your site key. It holds the
business's daily ceiling (50 runs in any 24 hours, which bounds the model
bill whatever happens) and past it refuses with
RecipeRefusedand aretryAfterSecondsof an hour. - Show
disclaimerunder the picture, every time. It is the business's own line, e.g. "An illustration, not a quote." - Serve
result.srcwithmediaHandler(below): it is/media/...on your origin. It comes withcache-control: private, max-age=86400, so the visitor's browser keeps it a day and no shared cache keeps it at all: it is a picture of someone's home. Do not put it through/cdn-cgi/image/, whose cache is shared. - The visitor's own photos go to the model and are never served back. The engine deletes them, and the pictures made from them, after 30 days.
- A refusal's message is plain enough to show: "Add a photo for room.", "The file for tile is not a photo we can use (JPEG, PNG, WebP or HEIC)." The engine reads a photo's type from its bytes, and takes up to 10 MB a photo and 24 MB a run. A call over 32 MiB never reaches the engine (the limit of one call over the binding), so hold uploads to size in the page.
http()cannot run recipes: the engine has no public address, so itsstartRecipeandrecipeRunthrow. Userpc(env.SEO, key).retryAfterSecondscrosses the binding only with enhanced error serialization, as for inquiries: acompatibility_dateof 2026-04-21 or later, or theenhanced_error_serializationflag.
The business's details and photos
The owner keeps the business's name, phone, email, address, social links and the places it serves on the dashboard. Read them rather than hard-coding them: the engine's LocalBusiness markup and its posts read the same record, so your footer cannot drift from either.
const profile = await seo().profile();
// E.164 ("+18327180760"): link it as-is, format it for display yourself.
{profile.telephone && <a href={`tel:${profile.telephone}`}>{formatPhone(profile.telephone)}</a>}
<p>Serving {profile.serviceArea.join(", ")}</p>Photos of finished work come the same way, in the owner's order. The list is
empty until the business adds photos on the dashboard, so keep your own as the
fallback. Their URLs are root-relative like post images: serve /media/* with
mediaHandler (below).
const photos = await seo().gallery();
const shown = photos.length > 0 ? photos : ownPhotos;Checking the URLs yourself? Use srcsetUrls(image.srcset): the transform URLs
contain commas (format=auto,width=768), so splitting a srcset on "," cuts
them apart.
The post, laid out as planned
Every post carries a layout plan made when it was written: how it opens,
which figure gets a caption and how wide, one or two lines lifted into pull
quotes, a key-points box, a tip or warning aside, what the ending asks for.
The engine decided those; your components decide what they look like.
compose() turns the plan into an ordered list to render:
import { compose } from "@jrcodex/seo-kit/compose";
<YourArticleLayout category={post.category}>
{compose(post).map((item, i) => {
switch (item.kind) {
case "opening": return <Opening key={i} treatment={item.treatment} title={item.title} excerpt={item.excerpt} hero={item.hero} />;
case "html": return <div key={i} className="prose" dangerouslySetInnerHTML={{ __html: item.html }} />;
case "figure": return <Figure key={i} placement={item.placement} caption={item.caption} html={item.html} />;
case "pull-quote": return <PullQuote key={i}>{item.text}</PullQuote>;
case "key-points": return <KeyPoints key={i} items={item.items} />;
case "aside": return <Aside key={i} note={item.note}>{item.text}</Aside>;
case "closing": return <Closing key={i} cta={item.cta} tone={item.tone} />;
}
})}
</YourArticleLayout>| item | what it carries |
|---|---|
| opening | treatment: hero-full, hero-inset, statement (no image, the excerpt large) or none; plus title, excerpt, hero |
| html | one top-level block of the body, in order (index into post.blocks) |
| figure | a body figure with placement (wide, inset-left, inset-right) and an optional caption that adds a fact |
| pull-quote | a sentence copied word-for-word from the body |
| key-points | 2–6 short points the body states |
| aside | 1–2 sentences with a note: tip, warning or cost |
| closing | what the end asks for: call, schedule, read-more or none; tone for styling |
Tell it what you have and it composes around it:
compose(post, { supports: { aside: false, placements: ["wide"] } })drops asides and renders every figure wide. Items you skip cost nothing: the
html and figure items alone are the complete body. Old posts and posts
whose plan did not pass the engine's checks arrive with the neutral plan (hero
on top, body as written), so a page never fails because its art direction did.
A site with its own judgement can replace compose() and keep the item shapes.
layoutFor(post, ["wide", "narrow", "split"]) still picks one of your page
templates per post, deterministically, if you also want variety at that level.
The list and the sitemap
const posts = await seo().posts(locale); // newest first, with hero, category, readingMinutes// app/sitemap.ts
import { sitemapEntries } from "@jrcodex/seo-kit/next";
export default async function sitemap() {
return [...ownPages, ...sitemapEntries(await seo().sitemap(), "https://acme.com")];
}Images
Post images are root-relative on your origin. Serve them from your Worker:
// worker/index.ts
import { mediaHandler } from "@jrcodex/seo-kit/worker";
const media = await mediaHandler(request, seo());
if (media !== null) return media;Cloudflare's image transformation on your zone then resizes them with no further configuration.
Rules the engine relies on
- Your locale routing must produce the engine's paths: the business's first
language unprefixed (
/blog/x), the others prefixed (/es/blog/y). Assert it in a test:post.locales[0] === routing.defaultLocale. - Render
post.html, or thehtmlandfigureitems fromcompose(), as-is. They are filtered by the engine; do not re-sanitize or re-parse them. Pull quotes, key points, asides and captions are plain text: escape them as you would any prop. - Never set hreflang or canonical by hand:
postMetadataderives them from the translation group, so both languages always point at each other.
Outside Cloudflare
http("https://engine.example", key) speaks the same contract over HTTPS with
the key as a bearer token.
Projects (0.5.0)
profile().team (0.5.1) says who does the work: "solo" means write "I", "crew" or null means "we". Read your hero, footer and about copy from it, so one profile change reaches every page.
One job, in the owner's words, with its photos - each a page. The owner makes them on the dashboard; your site shows them:
const list = await seo().projects("en"); // newest first
const one = await seo().project("en", slug); // null when there is noneProjectSummary carries title, summary, service (a taxonomy category:
link it to posts with the same category), locality, city, completedOn,
cover (a ProjectPhoto with src/srcset/sizes, served through
mediaHandler like post images), photoCount, path (/work/<slug>, or
/<locale>/work/<slug> outside the default locale) and alternates for
hreflang. Project adds bodyHtml (sanitised by the engine) and photos,
each with a role of before, after or detail, in the owner's order. A
project with no write-up in the locale you ask for comes back in the site's
default language, and says so in locale.
Declare it in your manifest as has: { projects: true } (or your own word:
portfolio, our_work), and the owner's dashboard gains the Projects page.
The customer chat, the FAQ and llms.txt (0.6.0)
A chat on your site that answers from what the owner chose to show, and ends,
when the visitor wants, in the same inquiry your form sends. Declare it as
has: { chat: true }; the owner turns it on from his dashboard (it is off
until he does).
import { chatHandler, llmsTxtHandler } from "@jrcodex/seo-kit/worker";
// 1. Start: YOUR route, after your bot check, exactly as for the form.
// POST /chat/start { turnstileToken, locale } -> { conversationId, expiresAt }
const started = await seo().startChat(locale); // throws ChatRefused: show the form
// 2. Connect and clear: the kit hands /chat/<id> to the engine with your key
// and none of the browser's cookies. GET is the WebSocket, DELETE is "Clear chat".
const chat = await chatHandler(request, seo());
if (chat !== null) return chat;
// 3. /llms.txt for AI crawlers, from the profile, the shown answers, projects and posts.
const llms = await llmsTxtHandler(request, seo());
if (llms !== null) return llms;The browser uses the Agents SDK's chat client against your own path:
useAgentChat with useAgent({ agent: "customer-chat-agent", basePath: "chat/" + conversationId }).
Keep the conversation in the browser (IndexedDB) for its seven days, with a
"Clear chat" button that sends the DELETE; the engine's copy is deleted after
seven days either way.
- Suggested questions and the FAQ section:
faq(locale)returns the owner's shown questions and answers, pinned first, then the most asked. Render an FAQ section withFAQPagemarkup, and show the first few as chips in the chat. A chip sends its question's exact text; the engine answers it from storage with no model call. - The lead card: when the visitor wants a visit or a quote, the chat's
offer_inquirytool part carries{ fields }named by yourintake.fields. Show it as an editable card; on Send, submit it through your own form route withsource: "chat". - Refusals:
ChatRefusedwhen the site has no chat, the owner has it off, the month's budget is spent or the day's conversations are used up. Its message is plain enough to show beside the form.
Over http() the chat and its FAQ are not available: the engine keeps no
public address for them.
Photos with the request (0.6.1)
When a visitor uses the visualizer and then asks for a quote, offer to send their photos with it, on by default and plainly worded: "Your photos go with your request." If they keep it on, put the run's id on the submission:
await seo().inquire({ locale, page, fields, recipeRunId: run.id });The owner sees their photos and the picture they saw on that inquiry, behind
sign-in. They are never on a public page, and they are kept as long as the
inquiry. Without recipeRunId a visitor's photos are deleted after 30 days
and the owner never sees them.
