[ Content & feeds ]
Join the IndieWeb: Webmention and Micropub
Mark posts up with microformats, receive and send Webmentions from background jobs, and accept posts over Micropub.
Last updated 2026-09-29
The IndieWeb is a set of small protocols that let independent sites talk to each other. Webmention tells a page it was linked to, so a reply written on someone else's site shows up under your post. Micropub lets a client you did not write publish to your site. Both read the same vocabulary, microformats2: class names on ordinary HTML that say which part of a page is the entry, its author and its date.
This guide adds all three to a Remix v3 app. It combines
@sdxc/microformats, @sdxc/webmention and
@sdxc/micropub, with @sdxc/jobs running every outbound
fetch off the request.
npm add remix @sdxc/microformats @sdxc/webmention @sdxc/micropub @sdxc/jobs \
@sdxc/result
The packages hold the protocols. Storing posts and mentions, moderation, and verifying access
tokens stay yours, so the examples call a few functions from your own ~/app/models/* modules.
Mark up your posts
A receiver reads your page's microformats to decide what your post is, and a Webmention you
send is only as good as the markup it points at. mf from @sdxc/microformats/ui is a mixin
that adds the class names, typed so a misspelled property is a compile error:
import type { Handle, RemixNode } from "remix/ui";
import { MicroTime, mf } from "@sdxc/microformats/ui";
interface PostEntryProps {
title: string;
url: string;
publishedAt: Date;
author: { name: string; url: string };
children: RemixNode;
}
export function PostEntry(handle: Handle<PostEntryProps>) {
return () => {
let { author, children, publishedAt, title, url } = handle.props;
return (
<article mix={[mf("h-entry")]}>
<h1 mix={[mf("p-name")]}>{title}</h1>
<a href={author.url} mix={[mf("p-author", "h-card")]}>
{author.name}
</a>
<a href={url} mix={[mf("u-url", "u-uid")]}>
<MicroTime property="dt-published" value={publishedAt}>
{publishedAt.toDateString()}
</MicroTime>
</a>
<div mix={[mf("e-content")]}>{children}</div>
</article>
);
};
}
MicroTime renders a <time> whose datetime is the ISO instant, so a parser reads an exact
time while the reader sees your formatted label. mf sits in mix beside your css() mixins
and appends its classes after theirs.
The parser that reads other people's pages reads yours too, which makes a template test short:
render the page, then parse(html, url) and readEntry(findItem(document, "h-entry")) should
hand back the title and author you rendered.
Receive Webmentions
The guide maps three kinds of route: your posts, the Webmention endpoint, and a Micropub
endpoint that answers both GET and POST:
import { get, post, route } from "remix/routes";
export default route({
posts: { show: get("/posts/:slug") },
webmention: post("/webmention"),
micropub: { query: get("/micropub"), write: post("/micropub") },
});
Advertise the endpoint first, in both places a sender looks. advertise from
@sdxc/webmention/discover writes the Link header value for a post's response:
import { advertise } from "@sdxc/webmention/discover";
import routes from "~/routes/web";
export function webmentionHeaders(base: URL) {
return { link: advertise(new URL(routes.webmention.href(), base)).header };
}
Pass it when you render a post, as in
ctx.render(<PostPage post={post} />, { headers: webmentionHeaders(ctx.url) }). The header
reaches a sender that only reads headers; a <link> reaches one that parses the page. Render
the element in your document layout's <head>, where every page carries it, beside the one a
Micropub client looks for:
import routes from "~/routes/web";
export function IndieWebLinks() {
return () => (
<>
<link rel="webmention" href={routes.webmention.href()} />
<link rel="micropub" href={routes.micropub.query.href()} />
</>
);
}
The endpoint itself does as little as possible. parseRequest checks the request in the order
the specification lists (form-encoded, both URLs present and public, source and target
different, a target you accept) and fetches nothing. Verification means fetching source, a
URL an anonymous caller chose, so it goes on the queue:
import { isFailure } from "@sdxc/result";
import { accepted, parseRequest, rejected } from "@sdxc/webmention/receiver";
import { createAction } from "remix/router";
import jobs from "~/app/jobs";
import { findPostByUrl } from "~/app/models/posts";
import routes from "~/routes/web";
export default createAction(routes.webmention, async (ctx) => {
let parsed = await parseRequest(ctx.request, {
formData: ctx.formData,
accepts: async (target) => (await findPostByUrl(ctx.db, target)) !== null,
});
if (isFailure(parsed)) return rejected(parsed.error);
let { source, target } = parsed.data;
await ctx.jobs.enqueue(jobs.webmentions.verify, {
source: source.href,
target: target.href,
});
return accepted();
});
The form-data middleware already read the body, so ctx.formData is passed in rather than read
again. rejected answers 400 with the reason as text, and accepted answers 202, which is
what tells the sender its mention is being processed rather than published.
ctx.jobs is published by jobEnqueuer(queue) from @sdxc/jobs/router, given the same queue
your dispatcher delivers from, so the request writes the message and none of the job's code
loads in the request path (see
Background jobs and cron). Declare the jobs
this guide uses in one map:
import { job, jobs } from "@sdxc/jobs";
import * as s from "remix/data-schema";
export default jobs({
webmentions: {
verify: job({ input: s.object({ source: s.string(), target: s.string() }) }),
send: job({ input: s.object({ postId: s.string() }) }),
deliver: job({
input: s.object({
postId: s.string(),
target: s.string(),
removed: s.boolean(),
}),
}),
},
});
Verify in the background
verify fetches the source under bounds (public hosts only, five redirects, one megabyte and
five seconds by default), checks that it links to the target, and summarizes it from its
microformats. Key what you store on the pair, since a repeated pair is an update:
import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";
import { verify } from "@sdxc/webmention/receiver";
import jobs from "~/app/jobs";
import { deleteMention, saveMention } from "~/app/models/mentions";
import { USER_AGENT } from "~/app/services/webmention";
export default createJobHandler(jobs.webmentions.verify, async (ctx) => {
let pair = {
source: new URL(ctx.input.source),
target: new URL(ctx.input.target),
};
let outcome = await verify(pair, { userAgent: USER_AGENT });
if (isFailure(outcome)) {
if (!outcome.error.retryable) return ctx.ack(outcome.error.message);
return ctx.retry({ delay: "10 minutes", cause: outcome.error });
}
if (outcome.data.status !== "linked") return await deleteMention(ctx.db, pair);
await saveMention(ctx.db, pair, outcome.data.mention, { status: "pending" });
ctx.log.set({ webmention: { kind: outcome.data.mention.kind } });
});
A timeout, a 5xx or a 429 is retryable; a refused host or an oversized body is not, and
retrying would get the same answer. gone (the source answered 410) and unlinked both mean
the mention no longer stands, so both delete it. USER_AGENT is a string naming your site, such
as Example Webmention (+https://example.com), so a publisher can see who is fetching.
A linked mention carries its kind (reply, like, repost, bookmark or mention), its
author and its content. content.html is already sanitized with the source as its base, so an
approved reply renders as it stands (in remix/ui, through unsafeHTML); mark each one up as
an mf("h-cite") so the replies under your post are themselves readable microformats.
Store mentions as pending and show them once you approve them; the endpoint is anonymous, and moderation is the one policy the protocol leaves entirely to you.
Send Webmentions
When a post is created, updated or deleted, enqueue jobs.webmentions.send with its id. The
send job works out which pages to notify: outboundLinks lists the links in the post's HTML
(other sites only, each once), and plan adds every page you notified before that the post no
longer links to, since the specification asks you to notify a removed link too. It already
runs inside a job, so it fans out through the dispatcher itself rather than ctx.jobs.
import { createJobHandler } from "@sdxc/jobs";
import { Markdown } from "@sdxc/markdown";
import { toHTML } from "@sdxc/markdown/html";
import { isFailure } from "@sdxc/result";
import { outboundLinks, plan } from "@sdxc/webmention/sender";
import jobs from "~/app/jobs";
import { dispatcher } from "~/app/jobs/dispatcher";
import { sentTargets } from "~/app/models/mentions";
import { findPost } from "~/app/models/posts";
export default createJobHandler(jobs.webmentions.send, async (ctx) => {
let post = await findPost(ctx.db, ctx.input.postId);
if (!post) return ctx.ack("The post no longer exists");
let source = new URL(post.url);
let parsed = Markdown.parse(post.content);
let html = post.deleted || isFailure(parsed) ? "" : toHTML(parsed.data.document);
let current = outboundLinks(html, source);
let linked = new Set(current.map((url) => url.href));
let { targets } = plan(current, await sentTargets(ctx.db, post.id));
await dispatcher.enqueueMany(
jobs.webmentions.deliver,
targets.map((url) => ({
postId: post.id,
target: url.href,
removed: !linked.has(url.href),
})),
);
});
A deleted post links nowhere, so every past target is notified. Serve 410 Gone from its URL
before the job runs, and each receiver's own verification sees the deletion and removes the
mention. One delivery per target means a slow endpoint delays nobody else:
import { createJobHandler } from "@sdxc/jobs";
import { isFailure } from "@sdxc/result";
import { WebmentionFetchError } from "@sdxc/webmention";
import { send } from "@sdxc/webmention/sender";
import jobs from "~/app/jobs";
import { forgetTarget, recordTarget } from "~/app/models/mentions";
import { findPost } from "~/app/models/posts";
import { USER_AGENT } from "~/app/services/webmention";
export default createJobHandler(jobs.webmentions.deliver, async (ctx) => {
let { postId, removed, target } = ctx.input;
let post = await findPost(ctx.db, postId);
if (!post) return ctx.ack("The post no longer exists");
let pair = { source: new URL(post.url), target: new URL(target) };
let result = await send(pair, { userAgent: USER_AGENT });
if (isFailure(result)) {
let error = result.error;
let transient =
error instanceof WebmentionFetchError
? error.retryable
: error.status >= 500 || error.status === 429;
if (transient) return ctx.retry({ delay: "30 minutes", cause: error });
return ctx.ack(error.message);
}
if (removed) return await forgetTarget(ctx.db, postId, target);
await recordTarget(ctx.db, postId, target, result.data.status);
});
send discovers the target's endpoint (the Link header first, then the first <link> or
<a> in the page) and POSTs the pair. It answers { status: "no-endpoint" } for a page that
takes no mentions, which is an ordinary outcome, not a failure. A failure is either a
WebmentionFetchError, which says itself whether it is retryable, or a
WebmentionSendError carrying the status the endpoint answered.
Accept posts over Micropub
A Micropub client finds your endpoint through the rel="micropub" link IndieWebLinks
renders, and signs in through IndieAuth, which ends with an access token you verify. The
micropub pair in the route table gives the endpoint both methods.
parseOperation decodes a POST in any of the three encodings clients use (form, multipart and
JSON) into one of four typed operations, and returns the token from wherever it was sent. Check
the token before you act on anything in the body:
import {
created,
deleted,
error,
parseOperation,
requiredScopes,
updated,
} from "@sdxc/micropub";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";
import { createPost, setDeleted, updatePost } from "~/app/models/posts";
import { verifyToken } from "~/app/services/indieauth";
import routes from "~/routes/web";
export default createAction(routes.micropub.write, async (ctx) => {
let parsed = await parseOperation(ctx.request, { formData: ctx.formData });
if (isFailure(parsed)) return error("invalid_request", parsed.error.message);
let { body: operation, accessToken } = parsed.data;
let scopes = accessToken === null ? null : await verifyToken(accessToken);
if (scopes === null) return error("unauthorized");
let needed = requiredScopes(operation);
if (!needed.some((scope) => scopes.has(scope))) {
return error("insufficient_scope", undefined, { scope: needed });
}
switch (operation.action) {
case "create":
return created(await createPost(ctx.db, operation));
case "update":
await updatePost(ctx.db, operation);
return updated();
case "delete":
case "undelete":
await setDeleted(ctx.db, operation.url, operation.action === "delete");
return deleted();
}
});
verifyToken is yours: it asks your token endpoint about the token and returns the scopes it
grants, or null. requiredScopes names the scopes any one of which authorizes the operation,
so a draft needs draft or create. Every response comes from the package: created is a
201 with Location, and error writes the status, the JSON body and the
WWW-Authenticate header the specification asks for.
A create arrives already reshaped: form fields become microformats2 properties, and mp-slug,
mp-syndicate-to and post-status are split out into commands. It has the shape of an
MF2.Item, so the microformats readers work on it directly:
import type { Micropub } from "@sdxc/micropub";
import { values } from "@sdxc/microformats";
import { postType } from "@sdxc/microformats/vocabulary";
export function draftFrom(operation: Micropub.Create) {
return {
kind: postType(operation),
title: values(operation, "name")[0] ?? null,
content: values(operation, "content")[0] ?? "",
tags: values(operation, "category"),
inReplyTo: values(operation, "in-reply-to")[0] ?? null,
slug: operation.commands.slug,
draft: operation.commands.status === "draft",
};
}
Your createPost can store what draftFrom reads and return the new post's URL, which
created puts in Location. postType runs Post Type Discovery, so a create with in-reply-to is a reply and one with a
name that is not a prefix of its content is an article. A reply you store this way is also
a post whose send job notifies the page it replies to, which is the loop that makes a
conversation across two sites.
The GET side answers queries. parseQuery reads q, and each answer has its own builder:
import { config, error, parseQuery, source, syndicateTo } from "@sdxc/micropub";
import { isFailure } from "@sdxc/result";
import { createAction } from "remix/router";
import { postAsItem } from "~/app/models/posts";
import { verifyToken } from "~/app/services/indieauth";
import routes from "~/routes/web";
export default createAction(routes.micropub.query, async (ctx) => {
let parsed = parseQuery(ctx.request);
if (isFailure(parsed)) return error("invalid_request", parsed.error.message);
let { body: query, accessToken } = parsed.data;
if (accessToken === null || (await verifyToken(accessToken)) === null) {
return error("unauthorized");
}
switch (query.q) {
case "config":
return config({ q: ["source", "syndicate-to"] });
case "syndicate-to":
return syndicateTo([]);
case "source": {
let item = await postAsItem(ctx.db, query.url);
if (!item) return error("invalid_request", "No such post");
return source(item, query.properties);
}
default:
return error("invalid_request", "Unsupported query");
}
});
List only the queries you answer in config({ q }), since clients treat a missing one as
unsupported. source writes the post back in the shape a JSON create sends, so a client's
editor round-trips it.
Where to go next
A markdown content pipeline — the HTML
outboundLinksreads comes fromtoHTML.Publish RSS, Atom and JSON feeds — the other half of being followed from someone else's site.
Background jobs and cron — the dispatcher, retries and the queue behind every job here.
@sdxc/microformats— the authorship algorithm, representativeh-cardand the rest of the vocabulary.