https://github.com/dodopayments/dualmark
Open-source AEO (Answer Engine Optimization) infrastructure — every page, dual-marked. Markdown twins for AI agents alongside HTML for humans, picked by HTTP content negotiation.
https://github.com/dodopayments/dualmark
aeo ai-bots ai-search ai-seo answer-engine-optimization astro-integration cloudflare-workers content-negotiation generative-engine-optimization geo llms-txt markdown nextjs open-source typescript
Last synced: 4 days ago
JSON representation
Open-source AEO (Answer Engine Optimization) infrastructure — every page, dual-marked. Markdown twins for AI agents alongside HTML for humans, picked by HTTP content negotiation.
- Host: GitHub
- URL: https://github.com/dodopayments/dualmark
- Owner: dodopayments
- License: apache-2.0
- Created: 2026-05-05T06:55:19.000Z (about 2 months ago)
- Default Branch: main
- Last Pushed: 2026-06-17T18:29:33.000Z (15 days ago)
- Last Synced: 2026-06-17T20:23:34.842Z (15 days ago)
- Topics: aeo, ai-bots, ai-search, ai-seo, answer-engine-optimization, astro-integration, cloudflare-workers, content-negotiation, generative-engine-optimization, geo, llms-txt, markdown, nextjs, open-source, typescript
- Language: TypeScript
- Homepage: https://dualmark.dev
- Size: 1.5 MB
- Stars: 88
- Watchers: 1
- Forks: 27
- Open Issues: 16
-
Metadata Files:
- Readme: README.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Codeowners: .github/CODEOWNERS
- Notice: NOTICE
Awesome Lists containing this project
- awesome-geo-cn - dodopayments/dualmark
README
# Dualmark
> The AEO infrastructure your marketing site is missing.
Your blog ranks #1 on Google. ChatGPT cites your competitor.
That's not a content problem. It's an **infrastructure problem**. AI search engines (ChatGPT, Claude, Perplexity, Gemini, Google AI Overviews) read the web differently from humans — they want clean markdown without nav chrome, JavaScript, or cookie banners. Most marketing sites give them HTML soup and wonder why they get ignored.
**Dualmark gives every page a markdown twin.** Same URL. Two formats. Picked by HTTP content negotiation. Drop it into your Astro/Next.js/SvelteKit/Cloudflare stack in 30 seconds. Score it with `dualmark verify`.
```diff
- npm install @next-seo/some-meta-tag-thing
+ bun add @dualmark/astro
```
[Quickstart](#quickstart) · [Why](#why-marketing-teams-need-this) · [Examples](./examples) · [Spec](./spec) · [Docs](https://dualmark.dev)
---
## Quickstart
### Astro (30 seconds)
```bash
bun add @dualmark/astro
```
```ts
// astro.config.mjs
import { defineConfig } from "astro/config";
import dualmark from "@dualmark/astro";
export default defineConfig({
site: "https://yourcompany.com",
integrations: [
dualmark({
siteUrl: "https://yourcompany.com",
collections: {
blog: { converter: "blog" }, // /blog/*.md auto-generated
glossary: { converter: "glossary" }, // /glossary/*.md auto-generated
},
llmsTxt: { enabled: true }, // /llms.txt auto-generated
}),
],
});
```
```bash
bun run build && bunx dualmark verify https://localhost:4321/blog/your-post
# → Score 80/80 ✓
```
That's it. Every blog post has a markdown twin at `/blog/.md`. `llms.txt` is generated. Every HTML response advertises its twin via `Link: <…>; rel="alternate"; type="text/markdown"`. ChatGPT crawler sees clean markdown. Your existing pages don't change.
### Next.js App Router (60 seconds)
```bash
bun add @dualmark/nextjs
```
```ts
// proxy.ts (or middleware.ts on Next ≤15)
import { createDualmarkMiddleware } from "@dualmark/nextjs";
export default createDualmarkMiddleware({ siteUrl: "https://yourcompany.com" });
export const config = {
matcher: [
{
source: "/((?!_next/|favicon.ico|md/).*)",
missing: [{ type: "header", key: "next-router-prefetch" }],
},
],
};
```
```ts
// app/md/[...path]/route.ts
import { createDualmarkRouteHandler } from "@dualmark/nextjs";
import { POSTS } from "@/lib/posts";
const handler = createDualmarkRouteHandler({
siteUrl: "https://yourcompany.com",
collections: {
blog: { converter: "blog", getEntries: () => POSTS.map(toEntry) },
},
});
export const dynamic = "force-static";
export const GET = handler.GET;
export const generateStaticParams = handler.generateStaticParams;
```
That's it. Bot UAs get markdown, browsers get HTML with a `Link rel="alternate"` header, direct `.md` URLs serve markdown. Full example with `next dev` → 120/125 conformance score:
[Full Next.js example →](./examples/nextjs-app-router)
### SvelteKit (60 seconds)
```bash
bun add @dualmark/sveltekit
```
```ts
// vite.config.ts
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import dualmark from "@dualmark/sveltekit";
import dualmarkConfig from "./src/dualmark.config";
export default defineConfig({
plugins: [dualmark(dualmarkConfig), sveltekit()],
});
```
```ts
// src/hooks.server.ts
import { createDualmarkHandle } from "@dualmark/sveltekit";
import dualmarkConfig from "./dualmark.config";
export const handle = createDualmarkHandle(dualmarkConfig);
```
Full example with `vite dev` → 125/125 conformance score:
[Full SvelteKit example →](./examples/sveltekit-blog)
### Cloudflare Workers (60 seconds)
Wrap your existing Worker. AI bots get markdown at the edge — single-digit-ms first-byte from 300+ cities.
```ts
import { createAEOWorker } from "@dualmark/cloudflare";
import upstream from "./your-existing-worker.js";
export default createAEOWorker({
upstream,
trailingSlash: "never",
analytics: { binding: "AI_AGENT_ANALYTICS" },
});
```
[Full example with `wrangler dev` → 125/125 conformance score →](./examples/astro-cloudflare-full)
### Deno Deploy (60 seconds)
Wrap any Deno fetch handler — a static-file reader or an existing app.
```ts
// main.ts
import { createAEOHandler } from "@dualmark/deno";
import myApp from "./app.ts";
Deno.serve(createAEOHandler({ upstream: myApp.fetch }));
```
[Full example with `deno run` → 125/125 conformance score →](./examples/deno-deploy)
### Vercel Edge (60 seconds)
Drop a middleware that serves your pre-built `.md` twins to AI bots at the edge.
```ts
// proxy.ts
import { NextResponse } from "next/server";
import { createAEOMiddleware } from "@dualmark/vercel";
export default createAEOMiddleware({
upstream: async () => NextResponse.next(),
fetchAsset: async (url, init) => fetch(url.toString(), init),
});
export const config = { matcher: ["/((?!_next/|favicon.ico).*)"] };
```
[Full Next.js + Vercel example → 120/125 →](./examples/vercel-edge-full)
---
## Why marketing teams need this
You already invested in SEO. Now invest in AEO — for **a fraction of the effort**.
| Problem | Without Dualmark | With Dualmark |
|---|---|---|
| **AI cites competitors instead of you** | Bots scrape your HTML, get nav menus + JS errors, pick the cleaner source | Same URL serves clean markdown to bots, polished HTML to humans |
| **No way to know if you're discoverable** | "We hope ChatGPT can read this" | `dualmark verify` returns a 0–125 score with line-item failures |
| **`llms.txt` proposal keeps changing** | Hand-maintained, drifts from sitemap | Auto-generated from the same config that drives your routes |
| **Every team rebuilds this** | Custom middleware in every repo, none of them quite right | One battle-tested package, conforms to a public spec |
| **No analytics for AI traffic** | "Was that a bot or a human?" | `onAIRequest` hook + Cloudflare Analytics Engine integration: bot name, vendor, page, tokens, country |
| **Slow to roll out across pages** | Marketing waits weeks for engineering | Add `converter: "compare"` to a collection — done. Production-tested converters bundled. |
**Built and battle-tested at [Dodo Payments](https://dodopayments.com)** for our own marketing site. Now extracted as OSS so you don't have to write the same content negotiation, bot detection, and edge wrapping over and over.
---
## What you actually ship
```
yourcompany.com/pricing ← human visitors get this
yourcompany.com/pricing.md ← AI agents get this
yourcompany.com/llms.txt ← AI agents discover everything
```
Same URL. Same content. Different rendering. Picked automatically by:
- `Accept: text/markdown` header → markdown
- Known AI bot User-Agent (GPTBot, ClaudeBot, PerplexityBot, +21 more) → markdown
- Direct `.md` URL → markdown
- Anything else → HTML, with `Link rel="alternate"` pointing to the twin
No duplicate content penalties (markdown twin sets `X-Robots-Tag: noindex`). No JS framework rewrites. No content team retraining. **Your existing pages stay the same.**
---
## Built-in converters (`@dualmark/converters`)
Drop-in markdown generation for the page types every marketing site has:
| Converter | What it's for | Marketing examples |
|---|---|---|
| `blog` | Long-form posts | Engineering blog, customer stories |
| `case-study` | Customer wins | Logos with stats and pull-quote |
| `changelog` | Release notes | "What's new in v1.4" with grouped changes |
| `compare` | Us vs. competitor | "Stripe alternative" pages |
| `docs` | Documentation | Getting started, API guides |
| `feature` | Product/feature pages | "Webhooks", "SSO" — problem/solution + FAQ |
| `glossary` | Term definitions | "What is a payment gateway?" |
| `integration` | App marketplace / integrations | "Connect Stripe to Acme", Slack connector pages |
| `legal` | Policy pages | Terms, Privacy, DPA |
| `pricing` | Pricing tables | Tier comparison with CTAs |
| `pseo` | Programmatic SEO | "SEO services in San Francisco" with facts + cross-links |
| `status-page` | Uptime / status | Public component health + incidents |
| `tool` | Standalone calculators | "Currency converter" |
| `video` | Video landing pages | Webinar replays |
Each converter takes your collection entry → returns clean markdown with the right structure for AI consumption (title, description, breadcrumbs, FAQ extraction, related links). No prompt engineering required.
```ts
import { compareConverter } from "@dualmark/converters";
const convert = compareConverter({
siteUrl: "https://yourcompany.com",
basePath: "/compare",
});
const md = convert(yourComparePage); // → battle-tested markdown layout
```
---
## Verify any site against the spec
```bash
bunx @dualmark/cli verify https://yourcompany.com/pricing
```
```
Dualmark Conformance Report
URL: https://yourcompany.com/pricing
Markdown: https://yourcompany.com/pricing.md
Score: 125/125
Duration: 107ms
Passed:
[+20] md.fetch — Markdown twin URL is reachable
[+10] md.contentType — Content-Type is text/markdown; charset=utf-8
[+10] md.tokensHeader — X-Markdown-Tokens header is present
[+10] md.noindex — X-Robots-Tag includes noindex
[+10] md.vary — Vary header includes Accept
[+10] md.body — Body is non-empty markdown
[+10] html.linkAlternate — HTML response advertises markdown twin
[+10] negotiation.botUa — GPTBot UA receives text/markdown
[+10] negotiation.acceptHeader — Accept: text/markdown receives text/markdown
...
```
Three conformance levels — **Basic** (60%), **Standard** (80%), **Advanced** (95%). Drop the score in your CI to prevent regressions.
```yaml
# .github/workflows/ci.yml
- run: bunx @dualmark/cli verify https://staging.yourcompany.com/pricing
# exits non-zero if any required check fails
```
---
## What's in the box
| Package | npm | Size | What it does |
|---|---|---|---|
| [`@dualmark/core`](./packages/core) | `npm i @dualmark/core` | 14 KB | Framework-agnostic primitives: content negotiation (RFC 7231), AI-bot detection (24 known bots), markdown response builder, token estimation, composition helpers, `llms.txt` rendering. Zero runtime deps. |
| [`@dualmark/converters`](./packages/converters) | `npm i @dualmark/converters` | 16 KB | Production-tested converter factories. |
| [`@dualmark/astro`](./packages/astro) | `npm i @dualmark/astro` | 22 KB | Astro 5 integration. Auto-generates `.md` endpoints, ships middleware, generates `llms.txt`. |
| [`@dualmark/nextjs`](./packages/nextjs) | `npm i @dualmark/nextjs` | 15 KB | Next.js App Router adapter. `withDualmark()`, `createDualmarkMiddleware()`, `createDualmarkRouteHandler()`, `createLlmsTxtHandler()`. |
| [`@dualmark/sveltekit`](./packages/sveltekit) | `npm i @dualmark/sveltekit` | 19 KB | SvelteKit adapter. Vite route generator, `createDualmarkHandle()`, generated `.md` endpoints, and `llms.txt`. |
| [`@dualmark/cloudflare`](./packages/cloudflare) | `npm i @dualmark/cloudflare` | 9 KB | Workers edge adapter. Wraps any upstream Worker. Hooks for analytics + telemetry. |
| [`@dualmark/fastly`](./packages/fastly) | `npm i @dualmark/fastly` | 9 KB | Fastly Compute edge adapter. Natively proxies to Fastly backends with background hooks. |
| [`@dualmark/vercel`](./packages/vercel) | `npm i @dualmark/vercel` | 9 KB | Vercel Edge Middleware adapter. `createAEOMiddleware()` wraps any upstream handler; serves pre-built `.md` with lifecycle hooks. |
| [`@dualmark/deno`](./packages/deno) | `npm i @dualmark/deno` | 8 KB | Deno Deploy edge adapter. Wraps any Deno fetch handler. Lifecycle hooks scheduled on `info.completed`. |
| [`@dualmark/cli`](./packages/cli) | `npm i -g @dualmark/cli` | 16 KB | `dualmark verify `. Programmatic API too. |
Plus:
- [**`spec/`**](./spec) — the **AEO Specification v1.0**. Public, framework-agnostic, RFC-2119-compliant. Implement it in Go, Rust, PHP, Ruby — your call.
- [**`apps/docs/`**](./apps/docs) — Fumadocs site at [dualmark.dev](https://dualmark.dev)
- [**`apps/docs/app/play`**](./apps/docs/app/play) — interactive Accept-header + UA tester. Live at [dualmark.dev/play](https://dualmark.dev/play).
- [**`examples/`**](./examples) — seven end-to-end working examples (Astro, Astro+Cloudflare, Sanity+Astro, Next.js, SvelteKit, Deno, Vercel).
---
## End-to-end verified
| Surface | Status |
|---|---|
| `@dualmark/core` | 174 tests pass (vitest + fast-check property tests) |
| `@dualmark/converters` | 31 tests pass |
| `@dualmark/cloudflare` | 23 tests pass |
| `@dualmark/fastly` | 19 tests pass |
| `@dualmark/deno` | 23 tests pass |
| `@dualmark/cli` | 25 tests pass |
| `@dualmark/astro` | 39 tests pass |
| `@dualmark/nextjs` | 47 tests pass |
| `@dualmark/sveltekit` | 17 tests pass |
| `@dualmark/vercel` | 28 tests pass |
| `examples/astro-blog` | **80/80** under `astro dev` (`--skip-negotiation`) |
| `examples/sanity-astro` | **80/80** under `astro dev` (`--skip-negotiation`) for fixture-backed blog + glossary docs |
| `examples/astro-cloudflare-full` | **125/125 perfect** under `wrangler dev` (full negotiation) |
| `examples/nextjs-app-router` | **120/125** under `next dev` (now using `@dualmark/nextjs`) |
| `examples/sveltekit-blog` | **125/125 perfect** under `vite dev` (full negotiation) |
| `examples/deno-deploy` | **125/125 perfect** under `deno run` (full negotiation) |
| `examples/fastly-compute` | **125/125 perfect** under `fastly compute serve` (full negotiation) |
| `examples/vercel-edge-full` | **120/125** under `next dev` (`@dualmark/vercel`) |
| `apps/docs` | 26 routes prerendered, all serve 200 |
| `/play` route | Live at dualmark.dev/play, integrated into the docs app |
```bash
bun install
bun run build && bun run test && bun run typecheck # 407 tests across 9 packages
```
---
## Where it goes from here
We're building toward Dualmark being **the** AEO infrastructure for marketing sites — the same way Tailwind became the default for marketing CSS or Vercel for marketing hosting. The roadmap:
- **More framework adapters**: Remix/React Router, Nuxt
- **More edge adapters**: Netlify
- **More converters**: pricing tables, changelog, docs/API reference, status pages, integrations
- **AEO Analytics**: a hosted dashboard on top of the `onAIRequest` hook, so marketing can see which bot reads which page, when
- **Spec evolution toward AEO 1.1+** with structured data hints, per-section markdown anchors, and sitemap.md
- **CMS integrations**: Sanity, Contentful, Builder.io plugins so non-engineers can author dual-marked content
If you're a marketing engineer reading this and any of those would land in your stack, [open an issue](https://github.com/dodopayments/dualmark/issues) or [+1 an existing one](https://github.com/dodopayments/dualmark/issues).
---
## Contributing
We're early. Issues, PRs, and "I tried it on $framework and it broke" reports are all welcome.
- Read [CONTRIBUTING.md](./CONTRIBUTING.md) for the dev loop and release flow.
- The AEO Spec is authoritative — if you implement it elsewhere (in any language), we want to link to your implementation.
```bash
bun install
bun run build # turbo-orchestrated build
bun run test # vitest across all packages
bun run typecheck
```
## License
Apache 2.0 — see [LICENSE](./LICENSE) and [NOTICE](./NOTICE). Includes a patent grant. Use it for anything; attribution appreciated, never required.
## Status
**Pre-1.0.** APIs may change in patch releases until 1.0. The AEO Spec v1.0 is authoritative; framework code follows. Production-ready for early adopters; we're [running it on dodopayments.com](https://dodopayments.com).