Inside Indiekit: How 30+ Plugins Turn a Node.js Server into a Federated Personal Web Platform
This post is a guided tour through the architecture of the system that powers this site.
It’s built on Indiekit, an open-source Node.js IndieWeb server created by Paul Robert Lloyd. I forked it because I wanted to change fundamental aspects of how it works — a new page post type for slash pages, OpenGraph card embeds in the Bluesky and Mastodon syndicators, full ActivityPub federation, content aggregation from external platforms, a social reader, a homepage builder, and more. The result is 27 @rmdes/* plugins that extend the original system far beyond its initial scope.
This level of extension — going from a handful of core IndieWeb features to a 30+ plugin personal web platform — was made possible by developing with Claude Code (Anthropic). It served as a pair-programming assistant throughout, helping me move fast without sacrificing code quality.
If you want the interactive version with diagrams, check out the Architecture Explorer.
What follows is the story of how all these pieces fit together.
1. The Core: What Indiekit Is
At its heart, Indiekit is an Express server with a plugin orchestration layer. It doesn’t do much on its own — its power comes from the plugins you load. The core provides five extension points:
addEndpoint()— registers HTTP routes. This is how plugins expose admin UIs, APIs, and protocol endpoints.addPostType()— defines content types (articles, notes, photos, pages) with their own properties and permalink patterns.addStore()— plugs in a storage backend. The default writes.mdfiles to the filesystem, but you could write to GitHub, GitLab, or anywhere else.addSyndicator()— registers targets for cross-posting. Each syndicator knows how to format and deliver content to its platform.addPreset()— integrates with a static site generator. The preset converts Indiekit’s internal JF2 data format into whatever your SSG expects.
Configuration lives in indiekit.config.js, loaded via cosmiconfig. You list your plugins, set your preferences, and Indiekit wires everything together at startup.
The key design principle is that every piece of functionality is a plugin. Authentication? A plugin. Content creation via Micropub? A plugin. The admin UI for managing posts? A plugin. This means you can run a minimal IndieWeb blog with just a handful of packages, or load 30+ plugins for a full-featured personal web platform.

2. The Plugin Taxonomy
With 30+ plugins, organization matters. They break down into six categories:
Core IndieWeb Endpoints (6 plugins)
These are the essential building blocks. endpoint-auth handles IndieAuth with JWT and PKCE. endpoint-micropub processes content creation requests — it’s the main entry point for posting. endpoint-posts provides the admin UI at /posts for managing content. endpoint-syndicate triggers cross-posting on a 2-minute polling schedule. Two webmention plugins handle outbound sending and inbound receiving via webmention.io.
Several of these are forks of the upstream Indiekit packages. The Micropub fork adds type-based post discovery. The syndicate fork adds batch mode with a 2-second delay between targets to avoid rate limiting. These are the kinds of practical modifications that emerge from running the system in production.
Social & Federation (3 plugins)
This is where things get interesting. The ActivityPub plugin turns the entire site into a fediverse actor — more on this in section 4. Microsub is a social reader with adaptive feed polling that ranges from 1-minute checks for active feeds down to 17-hour intervals for dormant ones. Blogroll aggregates blogs from OPML files, Microsub subscriptions, and FeedLand, with webhooks connecting it to the Microsub reader.
Content Aggregation (6 plugins)
Six plugins pull activity from external platforms on background schedules: RSS feeds every 15 minutes, Podroll (podcasts via FreshRSS) every 15 minutes, Funkwhale listening history every 5 minutes, Last.fm scrobbles every 5 minutes, GitHub activity, and YouTube channel data. Each stores its data in MongoDB and exposes an API that the Eleventy theme fetches at build time.
Site Management (3 plugins)
Homepage Builder provides a drag-and-drop admin UI for arranging homepage sections. It discovers available sections from other plugins — CV data, GitHub repos, Funkwhale listening stats, recent blog posts, and more. CV manages a structured resume with experience, education, skills, projects, and certifications. LinkedIn OAuth handles token management for the LinkedIn syndicator.
Syndicators (4 plugins)
Four cross-posting targets: Bluesky (AT Protocol with native rich text facets and OG card embeds), Mastodon (with native favorites and reblogs), LinkedIn (REST API for articles and notes), and IndieNews (webmention-based, no API key needed). Each understands interaction types — when you like or repost something, the syndicator sends the appropriate native interaction rather than creating a new post.
Post Types & Presets (2 plugins)
post-type-page creates root-level slash pages (/about, /now, /uses). preset-eleventy is a fork that generates permalinks for all post types, not just the ones upstream Indiekit supports. It converts JF2 content to YAML frontmatter with Markdown bodies — the format Eleventy expects.

3. The Complete Data Flow
The system has two main directions: outbound (publishing your content) and inbound (aggregating external activity).
Outbound: From Micropub to Published Page
When you write a post — using a Micropub client like Quill, the Indigenous app, or the admin UI at /posts — here’s what happens:
- Auth check.
endpoint-authvalidates your IndieAuth bearer token and checks scopes (create, update, delete). - Content processing.
endpoint-micropubreceives the JF2 content, determines the post type, and preserves anymp-syndicate-totargets for later. - Format conversion.
preset-eleventyconverts the JF2 data into a YAML frontmatter + Markdown file, generating the appropriate permalink (/articles/2026/02/25/my-post/). - Storage.
store-file-systemwrites the.mdfile to/app/data/content/{type}/. Post metadata goes into the MongoDBpostscollection for admin queries. - Build. Eleventy’s file watcher detects the change and triggers a build. The entire site is rebuilt, a new timestamped release directory is created, and a symlink swap makes it live — zero downtime.
- Serving. nginx serves the static site on port 3000, proxying admin and API routes to Indiekit on port 8080.
After publishing, two background processes kick in:
- The syndication poller (every 2 minutes) finds posts with pending
mp-syndicate-totargets and triggers each syndicator with a 2-second delay between them. - The webmention sender (every 5 minutes) scans post content for URLs and sends webmentions to any linked page that advertises a webmention endpoint.
Inbound: External Activity Flowing In
The reverse direction is simpler. Six aggregator plugins run on background schedules, fetch data from external APIs, and store it in MongoDB. When Eleventy rebuilds, _data/*.js files call plugin API endpoints to get the latest aggregated content, and the theme renders it.
The cycle looks like: External service → background sync → MongoDB → plugin API → Eleventy _data file → Nunjucks template → static HTML.

4. ActivityPub Federation
This is the most complex part of the system. The ActivityPub plugin, built on Fedify 2.0, turns the site into a full fediverse actor. People on Mastodon, Pleroma, Misskey, or any ActivityPub-compatible platform can follow the site and see new posts in their timeline.
Outbound: Publishing to the Fediverse
When a post is created, the plugin converts JF2 content to an ActivityStreams 2.0 activity using jf2ToAS2Activity(). Fedify’s ctx.sendActivity() then delivers it to every follower’s inbox. The conversion handles all post types — articles become Article objects, notes become Note, likes become Like activities, and so on.
Inbound: Receiving from the Fediverse
Remote servers POST activities to the inbox. Fedify routes them to handlers for each activity type: Follow, Undo, Like, Announce (boost), Create (new post from a followed account), and Delete. Each handler updates the appropriate MongoDB collection.
The Reader
Following accounts on the fediverse populates a timeline. Their posts arrive as Create activities, are stored in ap_timeline, and rendered in a reader UI that supports composing, liking, boosting, and following — all from the admin interface.
The Express-Fedify Bridge
One technical challenge: Fedify has an official @fedify/express adapter, but it doesn’t work correctly with mounted sub-apps due to path resolution issues. The plugin uses a custom bridge that reconstructs req.originalUrl and POST bodies to make Express and Fedify communicate properly.
What It Looks Like
The plugin manages 13 MongoDB collections (ap_followers, ap_following, ap_activities, ap_keys, ap_kv, ap_profile, ap_featured, ap_featured_tags, ap_timeline, ap_notifications, ap_muted, ap_blocked, ap_interactions) and exposes:
Public endpoints for federation:
/.well-known/webfinger— actor discovery/.well-known/nodeinfo— server metadata/activitypub/users/*— actor profile, inbox, outbox, collections- Content negotiation at
/— ActivityPub clients see AS2 JSON instead of HTML
Admin UI for management:
- Dashboard, reader with timeline and compose
- Profile editor, follower/following lists
- Mastodon account import (migration)
- Moderation (mute/block)

5. Inter-Plugin Relationships
Plugins don’t exist in isolation. Several have meaningful relationships with each other:
The Homepage Builder as Aggregator of Aggregators
endpoint-homepage discovers sections from other plugins at runtime. It knows about CV (5 section types), GitHub, Funkwhale, Last.fm, Blogroll, Podroll, YouTube, and Microsub. The admin UI lets you drag and drop these sections into a layout — single column, two-column with sidebar, or custom arrangements. When you save, it writes homepage.json, which triggers an Eleventy rebuild.
Microsub ↔ Blogroll
These two talk to each other. The Blogroll plugin can import feeds from Microsub channels, and when a new blog is added to the Blogroll, it notifies Microsub via webhook so the reader picks up the feed automatically.
The Publishing Chain
Content flows through a chain: Micropub (create the post) → Syndicate (trigger cross-posting) → Syndicators (deliver to platforms). The Micropub plugin preserves mp-syndicate-to targets in the post metadata. The syndicate endpoint polls for pending targets and dispatches to the appropriate syndicator. Each syndicator handles its platform’s API natively.
LinkedIn Token Sharing
The LinkedIn endpoint and syndicator are separate plugins that share state through environment variables. The endpoint handles the OAuth flow (authorization, token refresh) and stores tokens. The syndicator reads those tokens to authenticate API calls. This separation means the OAuth complexity doesn’t pollute the syndication logic.
The Three Social Outputs
When a post is published, it can reach three different networks simultaneously:
- ActivityPub → fediverse (Mastodon, Pleroma, etc.)
- Syndicators → social platforms (Bluesky, Mastodon API, LinkedIn)
- Webmention sender → IndieWeb sites
Yes, Mastodon appears twice — once via ActivityPub (federation, native) and once via the syndicator (API-based, for cases where you want explicit POSSE control). They serve different purposes.

6. Deployment Architecture
Everything runs inside a single Cloudron container. Three long-running processes share the filesystem:
nginx (:3000) — The Entry Point
nginx serves the static site from /app/data/site, which is a symlink to the current Eleventy build. Media files come from /app/data/content/media/. All admin routes, Micropub endpoints, and plugin APIs are proxied to Indiekit on port 8080. It also handles legacy URL redirects (/content/ → clean URLs) and security headers (CSP, X-Frame-Options).
Indiekit (:8080) — The Application
The Express server with 30+ plugins loaded via indiekit.config.js. It handles all dynamic operations: content creation, authentication, syndication, ActivityPub federation, and all 11 background sync processes. This is the only process that talks to MongoDB.
Eleventy — The Site Builder
A file watcher on /app/data/content/. When files change, it triggers a full build. The build creates a timestamped directory in /app/data/releases/, then atomically swaps the /app/data/site symlink — zero downtime. After building, it runs Pagefind for search indexing and notifies WebSub subscribers.
The Filesystem
/app/data/ # writable, backed up by Cloudron
├── config/ # indiekit.config.js, env.sh, .secret
├── content/ # user posts organized by type
│ ├── articles/
│ ├── notes/
│ ├── photos/
│ ├── likes/
│ ├── pages/
│ └── media/ # uploaded images
├── releases/ # timestamped Eleventy builds
├── site → # symlink to current release
└── cache/ # Eleventy build cache
MongoDB
Cloudron manages MongoDB. The database stores all state — 30+ collections covering posts, blogroll data, Microsub feeds, webmentions, RSS items, listening history, scrobbles, CV data, homepage configuration, LinkedIn tokens, podcast episodes, and all 13 ActivityPub collections.

7. The Eleventy Theme Layer
The theme is where data becomes a website. It’s a standalone repository used as a Git submodule in the deployment.
Data Sources
Eleventy’s _data/*.js files fetch data from three places:
Plugin APIs — endpoints like /blogrollapi/*, /funkwhale/api/*, /lastfm/api/*, /podrollapi/*, /github/api/*, /cv/data.json, and /homepage/api/*. These are the aggregator plugins exposing their MongoDB data via HTTP.
External APIs — YouTube Data API, GitHub REST API, Bluesky AT Protocol, and Mastodon API. These provide sidebar widgets with recent social activity that doesn’t go through Indiekit.
Static config — site.js (environment variables for site name, URL, author info), enabledPostTypes.js (which post types to show in navigation), homepageConfig.js (homepage layout), and cv.js (resume data).
Each data file follows the same pattern: try the Indiekit plugin API first, fall back to direct external API, return { source: "indiekit" | "api" | "error" } so templates can conditionally display content.
Templates
The template hierarchy is:
base.njk— the HTML shell with<head>, navigation, footer, and conditional sidebar logichome.njk— the homepage with plugin-driven layout or default hero + recent postspost.njk— individual posts with full microformat markup (h-entry), Bridgy syndication content, webmentions, reply contextpage.njk— static slash pages with sidebarfullwidth.njk— full-width pages for rich HTML content (like the Architecture Explorer)
Components include the homepage builder (section router, sidebar widgets), author h-card, reply context for interactions, and the webmentions display (likes, reposts, replies with avatars).
IndieWeb Compliance
Every post is marked up with Microformats2: h-entry for posts, h-card for the author, h-feed for lists, h-cite for reply context. The <head> includes rel="me" links for identity verification, authorization and token endpoints for IndieAuth, and Micropub/Microsub endpoint discovery.
For syndication via Bridgy, posts include hidden content with emoji prefixes and target URLs that Bridgy reads when cross-posting to Bluesky and Mastodon.
Frontend Stack
- Tailwind CSS with dark mode (
.darkclass toggle) - Alpine.js for interactive components (dropdowns, tabs, compose forms)
- Pagefind for client-side search
- lite-youtube-embed for lazy-loaded YouTube embeds
- is-land for island architecture — lazy-hydrated interactive widgets

8. Key Conventions
A few conventions keep the system consistent across 30+ plugins:
Dates are always ISO 8601 strings. new Date().toISOString(), never new Date(). The Nunjucks | date filter uses date-fns parseISO() which only accepts strings — passing a Date object crashes the template. Every template guards date filters with {% if value %} to handle nulls.
ESM everywhere. All plugins use "type": "module" in their package.json. No CommonJS, no build step.
Three auth layers. IndieAuth for admin routes (the user logs in), JWT for background processes (the syndication poller authenticates itself), HTTP Signatures for ActivityPub (remote servers verify identity).
Dual storage by design. MongoDB stores state and metadata — post records, aggregated content, ActivityPub data, configuration. The filesystem stores content — the actual .md files that Eleventy builds into the site. This separation means you can wipe the database and still have your content, or rebuild the database from the filesystem.
The publish lifecycle. Updating a plugin follows a strict sequence: bump version in package.json → commit and push → npm publish (manual, requires OTP) → update the Dockerfile version → cloudron build --no-cache && cloudron update. Skipping a step means the change doesn’t reach production.
The Big Picture
This is a personal web platform built from composable parts. At its core, it’s the IndieWeb stack — Micropub for publishing, webmentions for interactions, microformats for structured data. Layered on top is ActivityPub for fediverse federation, syndicators for platform cross-posting, and aggregators for pulling in external activity.
The result is a site that you fully own and control, that federates with the fediverse, cross-posts to social networks, aggregates your digital life, and serves as a static site for performance. All running inside a single container.
For the interactive version with navigable diagrams of each section, visit the Architecture Explorer.


Comments
Sign in with your website to comment:
Loading comments...
No comments yet. Be the first to share your thoughts!