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 .md files 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.

Indiekit core architecture diagram showing the five extension points and how plugins register

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.

Plugin taxonomy grid showing all 30+ plugins organized by category with color coding

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:

  1. Auth check. endpoint-auth validates your IndieAuth bearer token and checks scopes (create, update, delete).
  2. Content processing. endpoint-micropub receives the JF2 content, determines the post type, and preserves any mp-syndicate-to targets for later.
  3. Format conversion. preset-eleventy converts the JF2 data into a YAML frontmatter + Markdown file, generating the appropriate permalink (/articles/2026/02/25/my-post/).
  4. Storage. store-file-system writes the .md file to /app/data/content/{type}/. Post metadata goes into the MongoDB posts collection for admin queries.
  5. 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.
  6. 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-to targets 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.

Data flow diagram showing both outbound publishing pipeline and inbound aggregation

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)

ActivityPub federation diagram showing outbound/inbound flows, the Fedify bridge, and MongoDB collections

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.

Inter-plugin relationship diagram showing the Homepage Builder discovering from other plugins, and the publishing chain

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.

Deployment architecture diagram showing the three processes, filesystem layout, and MongoDB

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 configsite.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 logic
  • home.njk — the homepage with plugin-driven layout or default hero + recent posts
  • post.njk — individual posts with full microformat markup (h-entry), Bridgy syndication content, webmentions, reply context
  • page.njk — static slash pages with sidebar
  • fullwidth.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 (.dark class 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

Eleventy theme data flow showing _data files fetching from plugin APIs, external APIs, and static config

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:

Signed in as

Webmentions (4)

1 Like

3 Reposts

Send a Webmention

Have you written a response to this post? Send a webmention by entering your post URL below.