Uptoolkit
GitHub

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.json

Usage

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 now when rendering on the server for the same reason. A relative timestamp computed from Date.now() on both sides of a render will not agree.