Try it now
Highlight any sentence on this page. A small popup appears next to your selection — type a comment, optionally add a name, hit submit. The passage gets marked in amber, and the floating pencil button (bottom-right) shows your comment in a side panel.
Reload the page. The highlight is still there. Comments are stored in Cloudflare D1 and rendered on every visit. Other readers see what you wrote.
What it is
cf-comments is an anonymous, no-login comment layer for static sites hosted on Cloudflare Pages. The whole stack is:
- Frontend: @recogito/text-annotator loaded from esm.sh — no bundler needed
- Backend: Cloudflare Pages Functions (TypeScript) → Cloudflare D1
- Identity: none, ever. Optional display name persisted in
localStorage - Rate-limit: 10 comments per minute per IP-hash, enforced server-side
What it isn't
- It's not auth. Anyone can comment. If you need identity, this is the wrong tool.
- It's not Disqus. There's no platform, no embedded iframe, no analytics. Just SQL rows in your D1.
- It's not a real-time system. Comments only appear on page reload.
- It has no spam protection beyond IP rate-limiting. For high-traffic sites, add Cloudflare Turnstile to the POST handler.
Install
Point your agent at AGENTS.md in the
cf-comments repo.
It's a top-to-bottom runbook with concrete commands and verification
checks at every step. Roughly:
- Copy four files into your project (two assets, two Pages Functions)
- Apply the schema to a new D1 database
- Add three tags to your HTML shell — a CSS link, a config block, a module script
- Set two Pages secrets —
IP_HASH_SALTandADMIN_SECRET - Deploy
The full runbook handles the edge cases. ~10 minutes if you have wrangler authed.
Configuration
One inline <script> tag, set before loading the module:
<script>
window.__CFC_CONFIG = {
namespace: "my-blog-comments",
contentSelector: "article",
apiBase: "/api/annotations",
};
</script>
<link rel="stylesheet" href="/cf-comments.css">
<script type="module" src="/cf-comments.js"></script>
namespace prefixes localStorage keys so a user's display name
on site A doesn't pre-fill on site B. contentSelector picks
the element whose contents are annotatable — usually article,
main, or a more specific selector. apiBase only
needs changing if you've moved the Pages Functions routes from the default.
Moderation
Delete any comment via the API:
curl -X DELETE \
-H "X-Admin-Secret: <your-secret>" \
https://<your-site>/api/annotations/<id>
Soft-delete: the row stays in D1 with deleted = 1, hidden
from the API. To hard-delete, run a SQL command via wrangler.
How the highlights work
Comments anchor to the text via the
W3C Web Annotation Data Model
— specifically a TextQuoteSelector with prefix and suffix
context, plus a TextPositionSelector as a fallback. On page
load, cf-comments walks the DOM, finds the matching quote text, and
wraps it in a <mark class="df-anno-mark"> element.
The mark is inline with the text, not an overlay. That means the highlight
flows naturally with the body copy, survives reflow, and looks the same
to every reader on every device. It's also why multi-line quotes get
split into multiple <mark> elements that all share
the same data-id.
Limits worth knowing
- If the underlying page text changes after a comment is posted, the quote may no longer match. cf-comments will silently fail to anchor that comment, but the row stays in D1 and the comment still appears in the side panel.
- Cloudflare D1 has generous free-tier limits (5GB storage, ~5M reads/day), but if you expect high traffic, watch your usage.
- Pages Functions cold starts are ~50ms. Annotations should feel instant. If they don't, your D1 is probably in a different region than your Pages deployment.
Built by Matt Alldian. Source on GitHub. MIT license.