← The Store

Add-ons Registry

One catalog that powers the marketplace and the public tools page.

RegistriesLiveJavaScriptSupabase
13add-ons

What it is

A content-as-code registry of installable add-ons (analytics, social, productivity boards). The same source renders both the /profile/settings marketplace and the public /tools landing, so the two can never drift.

Take it with you

The real, committed source behind this system — copy it or download the file. Plus a portable spec of everything on this page.

registry.jsjavascript395 lines

The content-as-code add-ons registry — one source of truth that powers both the settings marketplace and the public /tools page.

// ---------------------------------------------------------------------------
// Add-ons marketplace — the catalog.
//
// PURE + client-safe (no server imports): the same module powers the browse UI,
// the API merge, and the config validation, so the marketplace and the runtime
// can never drift on what an add-on is or what config it needs.
//
// An add-on is code, not data — adding one is a registry entry plus (for the
// runtime ones) a small wiring point. State (connected? config?) lives in the
// addon_installs table (supabase/addons_schema.sql); the registry is the shape.
//
// scope:
//   "site" — affects every visitor (e.g. analytics tags). Owner-managed; at most
//            one site install per add-on.
//   "user" — a per-account connection; each user manages their own.
//
// status:
//   "available"   — connectable today.
//   "coming_soon" — listed in the marketplace, not yet wired (no connect).
//
// launchUrl (optional): a BUILT-IN app — there's nothing to configure, the
//   "install" is just opening it (e.g. Todo Boards → /t, where the board is
//   provisioned). The card renders an Open link instead of a connect form;
//   no addon_installs row is involved.
//
// fields[]: the config schema collected on connect. Each: { key, label,
//   placeholder?, help?, required?, pattern? (string RegExp), secret? }.
// ---------------------------------------------------------------------------

export const ADDON_CATEGORIES = {
  analytics: "Analytics",
  developer: "Developer",
  social: "Social",
  productivity: "Productivity",
  seo: "SEO",
  marketing: "Marketing",
  lifestyle: "Lifestyle",
};

export const ADDONS = [
  {
    id: "google-analytics",
    name: "Google Analytics 4",
    tagline: "Drop the GA4 tag on every page and measure traffic.",
    description:
      "Loads Google's gtag.js with your GA4 Measurement ID across the whole site so pageviews and events flow into your Analytics property. Site-wide — affects all visitors.",
    category: "analytics",
    scope: "site",
    accent: "#F7B538",
    monogram: "GA",
    status: "available",
    docsUrl: "https://support.google.com/analytics/answer/9304153",
    fields: [
      {
        key: "measurementId",
        label: "Measurement ID",
        placeholder: "G-XXXXXXXXXX",
        help: "GA4 Admin → Data Streams → your web stream. Starts with “G-”.",
        required: true,
        pattern: "^G-[A-Z0-9]{4,}$",
      },
    ],
  },
  {
    id: "plausible",
    name: "Plausible Analytics",
    tagline: "Lightweight, cookie-free privacy analytics.",
    description:
      "A privacy-friendly, GDPR-safe alternative to GA. Loads the Plausible script keyed to your domain on every page. Site-wide — affects all visitors.",
    category: "analytics",
    scope: "site",
    accent: "#003F88",
    monogram: "PL",
    status: "available",
    docsUrl: "https://plausible.io/docs",
    fields: [
      {
        key: "domain",
        label: "Domain",
        placeholder: "jakelawrence.xyz",
        help: "The site domain as registered in your Plausible account.",
        required: true,
        pattern:
          "^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)+$",
      },
    ],
  },
  {
    id: "giscus",
    name: "Giscus Comments",
    tagline: "GitHub-Discussions-powered comments on blog posts.",
    description:
      "Adds a comment thread to every blog post, backed by GitHub Discussions on a public repo. Readers sign in with GitHub to comment; threads are mapped by pathname. Site-wide — affects all blog readers.",
    category: "social",
    scope: "site",
    accent: "#6741D9",
    monogram: "GS",
    status: "available",
    docsUrl: "https://giscus.app",
    fields: [
      {
        key: "repo",
        label: "Repository",
        placeholder: "owner/repo",
        help: "A public repo with Discussions enabled and the giscus app installed. Get all four values from the giscus.app config generator.",
        required: true,
        pattern: "^[A-Za-z0-9][A-Za-z0-9._-]*/[A-Za-z0-9._-]+$",
      },
      {
        key: "repoId",
        label: "Repository ID",
        placeholder: "R_kgDO...",
        required: true,
        pattern: "^[A-Za-z0-9+/=_-]+$",
      },
      {
        key: "category",
        label: "Discussion category",
        placeholder: "Announcements",
        required: true,
      },
      {
        key: "categoryId",
        label: "Category ID",
        placeholder: "DIC_kwDO...",
        required: true,
        pattern: "^[A-Za-z0-9+/=_-]+$",
      },
    ],
  },
  {
    id: "cal-com",
    name: "Cal.com Booking",
    tagline: "A “Book a call” button on the hire page.",
    description:
      "Connect your Cal.com link and the public /hire page shows a Book-an-intro-call button next to the inquiry form, so a lead can grab time directly. Site-wide — affects all /hire visitors.",
    category: "marketing",
    scope: "site",
    accent: "#292929",
    monogram: "CA",
    status: "available",
    docsUrl: "https://cal.com/docs",
    fields: [
      {
        key: "calLink",
        label: "Cal.com link",
        placeholder: "jakelawrence/intro",
        help: "Your Cal.com link path — username or username/event-type (not the full URL).",
        required: true,
        pattern: "^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+){0,2}$",
      },
    ],
  },
  {
    id: "search-verification",
    name: "Search Console Verification",
    tagline: "Google + Bing site-verification tags from the UI.",
    description:
      "Renders the google-site-verification / msvalidate.01 meta tags from here instead of env vars (GOOGLE_SITE_VERIFICATION / BING_SITE_VERIFICATION remain the fallback when a token is blank). Site-wide.",
    category: "seo",
    scope: "site",
    accent: "#2B8A3E",
    monogram: "SV",
    status: "available",
    docsUrl: "https://support.google.com/webmasters/answer/9008080",
    fields: [
      {
        key: "googleToken",
        label: "Google verification token",
        placeholder: "abc123…",
        help: "Token only, not the full meta tag (Search Console → Settings → Ownership verification → HTML tag).",
        required: false,
        pattern: "^[A-Za-z0-9_-]{16,100}$",
      },
      {
        key: "bingToken",
        label: "Bing verification token",
        placeholder: "0123ABCD…",
        help: "Token only, not the full meta tag (Bing Webmaster Tools → meta-tag verification).",
        required: false,
        pattern: "^[A-Fa-f0-9]{16,64}$",
      },
    ],
  },
  {
    id: "buttondown",
    name: "Buttondown Newsletter",
    tagline: "An email signup box at the end of blog posts.",
    description:
      "Connect your Buttondown username and every blog post ends with a newsletter signup form posting straight to Buttondown — no client JS. Site-wide — affects all blog readers.",
    category: "marketing",
    scope: "site",
    accent: "#0069FF",
    monogram: "BD",
    status: "available",
    docsUrl: "https://buttondown.com/features/embeddable-forms",
    fields: [
      {
        key: "username",
        label: "Buttondown username",
        placeholder: "jakelawrence",
        help: "The username from your buttondown.com/<username> page.",
        required: true,
        pattern: "^[A-Za-z0-9][A-Za-z0-9_-]{1,63}$",
      },
    ],
  },
  {
    id: "todo-boards",
    name: "Todo Boards",
    tagline: "Your own to-do board at a private link — invite anyone.",
    description:
      "A personal kanban to-do board hosted here: due-date columns, recurring items, and members you invite by email. One click creates your board at its own unique URL; invitees see it after signing in. Per-account.",
    category: "productivity",
    scope: "user",
    accent: "#0B6E4F",
    monogram: "TB",
    status: "available",
    launchUrl: "/t",
    demoUrl: "/t/demo",
    reelSlug: "todo-boards-reel",
    fields: [],
  },
  {
    id: "weather-dashboards",
    name: "Weather",
    tagline: "Your own family weather board — every city you care about, one glance.",
    description:
      "A personal weather dashboard hosted here: add any city by search or your current location, label who lives there, and invite family by email to share the view. One click creates your board at its own private URL; invitees see it after signing in. Per-account.",
    category: "lifestyle",
    scope: "user",
    accent: "#64D2FF",
    monogram: "WX",
    status: "available",
    launchUrl: "/w",
    demoUrl: "/w/demo",
    reelSlug: "weather-boards-reel",
    fields: [],
  },
  {
    id: "trips",
    name: "Trips",
    tagline: "Spin up a trip — itinerary, the people on it, and the crew at home.",
    description:
      "A private space per trip hosted here: build the itinerary stop by stop, invite everyone by email with real roles (travelers and a first-class “home” crew), and turn on the modules your trip needs — journal, chat, money split, plan, packing, notes, and the long-distance set: a check-in heartbeat, a cross-timezone call window, and a map of stops + everyone’s latest check-in. Pick a shape to start (“Away & home” or “Group getaway”); one click creates the trip at its own private URL. Per-account.",
    category: "lifestyle",
    scope: "user",
    accent: "#0B6E4F",
    monogram: "TR",
    status: "available",
    launchUrl: "/trip",
    demoUrl: "/trip/demo",
    reelSlug: "trip-board-reel",
    fields: [],
  },
  {
    id: "countdowns",
    name: "Countdowns",
    tagline: "A shared board of the days you’re counting toward.",
    description:
      "Birthdays, trips, “home in 12 days” — a shared countdowns board hosted here. Add a date (mark birthdays as yearly), invite people by email, and subscribe to the whole board in your own calendar (.ics). One click creates your board at its own private URL. Per-account.",
    category: "lifestyle",
    scope: "user",
    accent: "#BF5AF2",
    monogram: "CD",
    status: "available",
    launchUrl: "/cd",
    demoUrl: "/cd/demo",
    reelSlug: "countdowns-reel",
    fields: [],
  },
  {
    id: "setup-center",
    name: "Setup Center",
    tagline: "Connect every third-party integration, dummy-proof.",
    description:
      "The guided setup surface over the secrets store: every integration the platform can use as a checkable task — where to get each key, exactly what to paste, mint-in-place bearer tokens, and the off-app halves (GitHub Actions mirrors, Vercel env, provider webhooks) as steps with deep links. Owner-only; values stay write-only.",
    category: "developer",
    scope: "site",
    accent: "#003F88",
    monogram: "SC",
    status: "available",
    launchUrl: "/platform/secrets",
    fields: [],
  },
  {
    id: "github-activity",
    name: "GitHub Activity",
    tagline: "Show your recent commits on your profile.",
    description:
      "Connect a GitHub username to surface recent public activity on your profile page. Per-account.",
    category: "developer",
    scope: "user",
    accent: "#111111",
    monogram: "GH",
    status: "available",
    docsUrl: "https://docs.github.com/rest",
    fields: [
      { key: "username", label: "GitHub username", placeholder: "octocat", required: true, pattern: "^[A-Za-z0-9-]{1,39}$" },
    ],
  },
  {
    id: "studio-director",
    name: "Studio Director",
    tagline: "Turn your site's content into a YouTube channel that runs itself.",
    description:
      "Point it at your site and it composes your posts, essays, and weekly changelog into reviewed video projects — an LLM writes the script, hook variants, and thumbnail, you approve each drop, and it films + uploads on a cadence, then learns from what retains. Per-account; connects your own channel. Self-serve onboarding is in the works — proven on jakelawrence.xyz first.",
    category: "marketing",
    scope: "user",
    accent: "#c9a7ff",
    monogram: "SD",
    status: "coming_soon",
    docsUrl: "https://www.jakelawrence.xyz/studio",
    fields: [],
  },
];

const BY_ID = Object.fromEntries(ADDONS.map((a) => [a.id, a]));

export function getAddon(id) {
  if (!id) return null;
  return BY_ID[id] || null;
}

export function categoryLabel(slug) {
  return ADDON_CATEGORIES[slug] || slug;
}

export function isConnectable(addon) {
  return !!addon && addon.status === "available";
}

// The free, launchable multi-tenant boards: available user-scope add-ons that
// are a built-in app (have a launchUrl), as opposed to connect-style user
// add-ons (e.g. github-activity) which live only in the marketplace. The /tools
// landing and the /platform console's "Tools" grid both derive from this, so
// they can't drift from each other or accidentally surface a non-launchable
// add-on as a tile.
export function freeUserApps() {
  return ADDONS.filter(
    (a) => a.scope === "user" && a.status === "available" && a.launchUrl
  );
}

// ---------------------------------------------------------------------------
// Config validation — the single gate used by both the API (on write) and the
// runtime (defensively). Returns { ok, errors:{ [key]: msg }, value }, where
// `value` contains ONLY known field keys, trimmed. Unknown keys are dropped.
// ---------------------------------------------------------------------------
export function validateAddonConfig(addonId, rawConfig) {
  const addon = getAddon(addonId);
  if (!addon) return { ok: false, errors: { _addon: "Unknown add-on" }, value: {} };

  const input = rawConfig && typeof rawConfig === "object" ? rawConfig : {};
  const value = {};
  const errors = {};

  for (const field of addon.fields || []) {
    const raw = input[field.key];
    const str = raw == null ? "" : String(raw).trim();

    if (!str) {
      if (field.required) errors[field.key] = `${field.label} is required.`;
      continue;
    }
    if (field.pattern) {
      let re;
      try {
        re = new RegExp(field.pattern);
      } catch {
        re = null;
      }
      if (re && !re.test(str)) {
        errors[field.key] = `${field.label} doesn’t look right.`;
        continue;
      }
    }
    value[field.key] = str;
  }

  return { ok: Object.keys(errors).length === 0, errors, value };
}

// Strip secret-marked fields from a config blob (for read responses to callers
// who can't manage the add-on). No add-on stores a secret yet; this keeps the
// boundary honest for when one does.
export function redactConfig(addonId, config) {
  const addon = getAddon(addonId);
  if (!addon || !config) return {};
  const out = { ...config };
  for (const field of addon.fields || []) {
    if (field.secret && out[field.key] != null) out[field.key] = "••••••";
  }
  return out;
}

Where it lives

  • src/lib/addons/registry.js
  • src/app/profile/settings/add-ons/

See it in action

Free Tools

FAQ

Why one registry for two surfaces?

The marketplace and the public /tools page derive their tiles from the same array, so a new add-on appears in both at once and they can never drift apart.

Part of these stacks

Related systems

Utilities RegistrySixty-plus client-side browser tools, each its own SEO page.Command RegistryOne declarative list of every destination and action on the site.Architecture MapA living, interactive map of how the whole system communicates.

Explore the full catalog →

Want a system like this built for you?Work with me →