Stedefast

Modules

Writing a Module

5 min read

A Stedefast module is any object that implements the StedefastModule interface. You can publish your own modules as npm packages or define them inline in your config.

This guide walks through building a Reactions module — emoji reactions (👍 👎 ❤️) stored in Cloudflare KV.

The StedefastModule interface

interface StedefastModule {
  id: string;                   // unique identifier, e.g. "reactions"
  name: string;                 // human name, e.g. "Reactions"
  version: string;

  // Called during `stedefast build` — write static data to outputDir
  buildStaticExport(ctx: BuildContext): Promise<Record<string, string>>;

  // Cloudflare Pages Function handlers
  workerHandlers: WorkerHandler[];

  // Path to the island component bundle
  islandComponentPath: string;

  // Optional: inject scripts/styles into <head>
  headAssets?: { scripts?: string[]; styles?: string[] };

  // Optional: register an admin panel page
  adminRoute?: { segment: string; componentPath: string; label: string };

  // Cloudflare binding names required in wrangler.toml
  kvBindings?: string[];
  d1Migrations?: string[];
}

Step 1: Define the module factory

// src/index.ts
import type { StedefastModule, BuildContext } from "@stedefast/module-interface";

export interface ReactionsConfig {
  allowedEmoji?: string[];
}

export function ReactionsModule(config: ReactionsConfig = {}): StedefastModule {
  const allowedEmoji = config.allowedEmoji ?? ["👍", "👎", "❤️"];

  return {
    id: "reactions",
    name: "Reactions",
    version: "0.1.0",

    kvBindings: ["REACTIONS_KV"],

    buildStaticExport,
    workerHandlers,
    islandComponentPath: new URL("./island/ReactionsIsland.js", import.meta.url).pathname,
  };
}

Step 2: Write buildStaticExport

buildStaticExport is called at build time. It receives a BuildContext with the site config and list of content nodes. Write your pre-built data to outputDir.

async function buildStaticExport(ctx: BuildContext): Promise<Record<string, string>> {
  // In a real module, you'd read from KV here via a Cloudflare API client.
  // For the initial build on an empty KV, write zero counts for all pages.
  const counts: Record<string, Record<string, number>> = {};

  for (const node of ctx.contentNodes) {
    counts[node.id] = Object.fromEntries(
      (ctx.config.allowedEmoji ?? ["👍", "👎", "❤️"]).map(e => [e, 0])
    );
  }

  const outputPath = `${ctx.outputDir}/data/reactions/counts.json`;
  await writeFile(outputPath, JSON.stringify(counts, null, 2));

  // Return a map of written file paths (used in the build summary)
  return { "data/reactions/counts.json": outputPath };
}

Step 3: Write worker handlers

Worker handlers run as Cloudflare Pages Functions. Each handler declares a method, a path (relative to /_modules/<id>/), and an async handler function.

Handlers receive env typed as ModuleEnv — a provider-abstracted environment rather than raw Cloudflare bindings. This keeps your module testable and portable. See Provider Architecture for the full picture.

import type { ModuleEnv, WorkerHandler } from "@stedefast/module-interface";

// Extend ModuleEnv to declare any module-specific config
interface ReactionsEnv extends ModuleEnv {
  REACTIONS_MAX_PER_USER?: string;
}

export const reactHandler: WorkerHandler<ReactionsEnv> = {
  method: "POST",
  path: "/react",
  handler: async ({ request, env }) => {
    const kv = env.providers.kv;
    if (!kv) {
      return Response.json({ error: "KV store not configured" }, { status: 503 });
    }

    const body = await request.json() as { pageId?: string; emoji?: string };
    if (!body.pageId || !body.emoji) {
      return Response.json({ error: "Missing pageId or emoji" }, { status: 400 });
    }

    const key = `reactions:${body.pageId}:${body.emoji}`;
    const current = Number((await kv.get(key)) ?? "0");
    await kv.put(key, String(current + 1));

    return Response.json({ pageId: body.pageId, emoji: body.emoji, count: current + 1 });
  },
};

export const workerHandlers = [reactHandler];

Notice that env.providers.kv is checked for undefined before use — if no KV provider is configured for the site, the handler returns a 503 rather than crashing. This makes modules composable: you can install a module without providing every binding it could use.

Testing worker handlers

Because handlers depend on providers interfaces rather than CF-specific types, you can test them without Miniflare:

import type { KVStore } from "@stedefast/providers";
import { describe, expect, it } from "vitest";
import { reactHandler } from "./handlers.js";

function mockKV(initial: Record<string, string> = {}): KVStore {
  const store = new Map(Object.entries(initial));
  return {
    get: async (key) => store.get(key) ?? null,
    put: async (key, value) => { store.set(key, value); },
    delete: async (key) => { store.delete(key); },
    list: async () => ({ keys: [], list_complete: true }),
    getWithMetadata: async (key) => ({ value: store.get(key) ?? null, metadata: null }),
  };
}

describe("reactHandler", () => {
  it("increments a reaction count", async () => {
    const kv = mockKV({ "reactions:my-post:👍": "4" });

    const response = await reactHandler.handler({
      request: new Request("https://example.com/_modules/reactions/react", {
        method: "POST",
        body: JSON.stringify({ pageId: "my-post", emoji: "👍" }),
      }),
      env: {
        ENVIRONMENT: "development",
        SITE_BASE_URL: "https://example.com",
        BETTER_AUTH_SECRET: "secret",
        BETTER_AUTH_URL: "https://example.com",
        providers: { kv },
        raw: {},
      } as any,
      ctx: undefined as any,
      params: {},
    });

    expect(response.status).toBe(200);
    const json = await response.json() as { count: number };
    expect(json.count).toBe(5);
  });
});

Step 4: Write the island

The island is a React component that runs in the browser. It receives pageId as a prop and loads the pre-built JSON.

// src/island/ReactionsIsland.tsx
import { useState, useEffect } from "react";

interface Props {
  pageId: string;
  allowedEmoji?: string[];
}

export default function ReactionsIsland({ pageId, allowedEmoji = ["👍", "👎", "❤️"] }: Props) {
  const [counts, setCounts] = useState<Record<string, number>>({});

  useEffect(() => {
    fetch("/data/reactions/counts.json")
      .then(r => r.json<Record<string, Record<string, number>>>())
      .then(all => setCounts(all[pageId] ?? {}));
  }, [pageId]);

  async function react(emoji: string) {
    // Optimistic update
    setCounts(c => ({ ...c, [emoji]: (c[emoji] ?? 0) + 1 }));
    await fetch("/_modules/reactions/react", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ pageId, emoji }),
    });
  }

  return (
    <div style={{ display: "flex", gap: 8 }}>
      {allowedEmoji.map(emoji => (
        <button key={emoji} onClick={() => react(emoji)}>
          {emoji} {counts[emoji] ?? 0}
        </button>
      ))}
    </div>
  );
}

Step 5: Register in stedefast.config.ts

import { defineConfig } from "@stedefast/core";
import { createCloudflareProvider } from "@stedefast/provider-cloudflare";
import { ReactionsModule } from "./src/reactions/index.js";

export default defineConfig({
  // ...
  providers: createCloudflareProvider({
    kvBinding: "REACTIONS_KV",
  }),
  modules: [ReactionsModule({ allowedEmoji: ["👍", "❤️", "🚀"] })],
  cloudflare: {
    projectName: "my-site",
    kvNamespaces: [{ binding: "REACTIONS_KV", namespaceId: "YOUR_KV_ID" }],
  },
});

The providers field is what wires your Cloudflare KV namespace into env.providers.kv at runtime. Without it, env.providers.kv will be undefined and your handler will return 503.

Publishing a module

To publish a module as an npm package, follow the same structure as @stedefast/module-claps:

  1. Create a package with "type": "module" and a tsup build
  2. Export the module factory from the package root
  3. Include the island component in the files array
  4. Add "@stedefast/module-interface": "workspace:*" (or the npm version) as a peer dependency
  5. Add "@stedefast/providers": "workspace:*" as a dependency if your handlers access provider interfaces directly
  6. Publish to npm with a stedefast-module- prefix for discoverability