ActivityPub
ActivityStreams 2.0 types and helpers for building federated social features.
- Handle
- @ada@social.example
- WebFinger
- https://social.example/.well-known/webfinger?resource=acct%3Aada%40social.example
- Audience
- {"to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://social.example/users/ada/followers"]}
- Visibility
- followers
{
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/users/ada/activities/create/demo",
"type": "Create",
"actor": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/users/ada",
"type": "Person",
"preferredUsername": "ada",
"name": "Ada Okoye",
"summary": "<p>Front-end engineer. Building small tools for large groups of people.</p>",
"url": "https://social.example/users/ada",
"icon": {
"type": "Image",
"mediaType": "image/svg+xml",
"url": "https://api.dicebear.com/9.x/thumbs/svg?seed=ada",
"name": "Ada Okoye avatar"
},
"image": {
"type": "Image",
"mediaType": "image/svg+xml",
"url": "https://api.dicebear.com/9.x/shapes/svg?seed=ada-cover",
"name": "Ada Okoye cover image"
},
"inbox": "https://social.example/users/ada/inbox",
"outbox": "https://social.example/users/ada/outbox",
"followers": "https://social.example/users/ada/followers",
"following": "https://social.example/users/ada/following",
"published": "2020-01-14T10:30:00.000Z",
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "https://ada.example",
"verifiedAt": "2025-06-10T15:00:00.000Z"
},
{
"type": "PropertyValue",
"name": "Location",
"value": "Lagos, Nigeria"
},
{
"type": "PropertyValue",
"name": "Pronouns",
"value": "she/her"
}
]
},
"object": {
"id": "https://social.example/users/ada/posts/demo",
"type": "Note",
"attributedTo": {
"@context": "https://www.w3.org/ns/activitystreams",
"id": "https://social.example/users/ada",
"type": "Person",
"preferredUsername": "ada",
"name": "Ada Okoye",
"summary": "<p>Front-end engineer. Building small tools for large groups of people.</p>",
"url": "https://social.example/users/ada",
"icon": {
"type": "Image",
"mediaType": "image/svg+xml",
"url": "https://api.dicebear.com/9.x/thumbs/svg?seed=ada",
"name": "Ada Okoye avatar"
},
"image": {
"type": "Image",
"mediaType": "image/svg+xml",
"url": "https://api.dicebear.com/9.x/shapes/svg?seed=ada-cover",
"name": "Ada Okoye cover image"
},
"inbox": "https://social.example/users/ada/inbox",
"outbox": "https://social.example/users/ada/outbox",
"followers": "https://social.example/users/ada/followers",
"following": "https://social.example/users/ada/following",
"published": "2020-01-14T10:30:00.000Z",
"attachment": [
{
"type": "PropertyValue",
"name": "Website",
"value": "https://ada.example",
"verifiedAt": "2025-06-10T15:00:00.000Z"
},
{
"type": "PropertyValue",
"name": "Location",
"value": "Lagos, Nigeria"
},
{
"type": "PropertyValue",
"name": "Pronouns",
"value": "she/her"
}
]
},
"content": "Hello, fediverse.",
"published": "2025-06-12T14:00:00.000Z",
"to": [
"https://social.example/users/ada/followers"
],
"cc": []
},
"published": "2025-06-12T14:00:00.000Z",
"to": [
"https://social.example/users/ada/followers"
],
"cc": []
}Installation
npx shadcn@latest add https://ui.uptoolkit.com/r/activitypub.jsonUsage
The data contract every other social item in this registry reads. It models the
ActivityStreams 2.0 vocabulary that
ActivityPub servers actually serve, so objects you fetch from
an outbox or inbox can be handed straight to the components with no adapter layer.
No dependencies, no runtime schema validation, no network calls.
Types
ActivityPubActor, ActivityPubObject, ActivityPubActivity, ActivityPubCollection,
ActivityPubDocument, ActivityPubTag, ActivityPubPropertyValue, and ActivityPubPlace.
Identity
import { buildWebFingerUrl, getActorAcctUri, getActorHandle } from "@/lib/activitypub";
getActorHandle(actor); // "@mira@fediverse.example"
getActorAcctUri(actor); // "acct:mira@fediverse.example"
buildWebFingerUrl("@mira@fediverse.example");
// "https://fediverse.example/.well-known/webfinger?resource=acct%3Amira%40fediverse.example"Audience and visibility
Mastodon-style visibility is not a field in ActivityStreams: it is derived from to and cc.
getVisibility reads it, buildAudience writes it.
import { buildAudience, getVisibility } from "@/lib/activitypub";
getVisibility(note); // { visibility: "public" | "unlisted" | "followers" | "direct" }
buildAudience({ visibility: "followers", actor, mentions: [otherActor.id] });
// { to: ["https://social.example/users/ada/followers"], cc: ["https://..."] }Building activities
Each helper returns a complete activity, ready to POST to the actor's outbox.
import {
createAnnounceActivity,
createFollowActivity,
createLikeActivity,
createNoteActivity,
createUndoActivity,
} from "@/lib/activitypub";
const create = createNoteActivity({ actor, content: "Hello, fediverse.", visibility: "public" });
const like = createLikeActivity({ actor, object: note });
const react = createLikeActivity({ actor, object: note, content: "\u{1F602}" }); // EmojiReact
const boost = createAnnounceActivity({ actor, object: note });
const follow = createFollowActivity({ actor, object: otherActor });
const undo = createUndoActivity({ actor, activity: like });Reading
getCollectionItems and getCollectionCount normalise items / orderedItems / totalItems.
getActivityObject unwraps Create and Announce. toPlainText strips the HTML that federated
content arrives as, and getObjectParagraphs splits it into renderable paragraphs.
Formatting
formatPublishedTime, formatPublishedDate, and formatCompactNumber default to the en locale
rather than the host locale, because Intl with an implicit locale resolves differently on a server
and in a visitor's browser and surfaces as a hydration mismatch. Pass locale to opt in to
something else — the same value on both sides.
formatPublishedTime(note.published, { now: renderedAt, locale: "fr" }); // "il y a 3 heures"
formatPublishedDate(actor.published, { locale: "fr" }); // "juillet 2019"
formatCompactNumber(18_940); // "19K"Pass a fixed
nowwhen rendering on the server for the same reason. A relative timestamp computed fromDate.now()on both sides of a render will not agree.