Development

Extend Thunder with reusable plugins, or turn any Thunder project into one.

Thunder uses a unified project-plugin architecture: a Thunder project is a Thunder plugin. There is no special structure to learn - any project can be published to GitHub and installed into another project as a plugin.

How It Works

Create a Thunder project

Build your feature as a normal Thunder project with routes/, hooks/, and schemas/.

Publish it to GitHub

Push the project to a public (or accessible) GitHub repository.

Install it as a plugin

Add it to any other Thunder project. Its routes and hooks are automatically discovered and merged with the main app.

Plugins are installed under plugins/{org}/{name}/:

Managing Plugins

# Add a plugin from GitHub
deno task add:plugin -n org/plugin-name

# Add a plugin AND run its setup lifecycle (indexes, seeding, migrations).
# `--setup` alone targets the "production" env; pass a comma list to target others.
deno task add:plugin -n org/plugin-name --setup
deno task add:plugin -n org/plugin-name --setup=development,production

# Run a plugin's setup lifecycle separately (after install)
deno task setup:plugin -n org/plugin-name                    # interactive env selection
deno task setup:plugin -n org/plugin-name --envs=development  # non-interactive

# Update a plugin to its latest version
deno task update:plugin -n org/plugin-name

# Remove a plugin (optionally run its cleanup lifecycle)
deno task remove:plugin -n org/plugin-name
deno task remove:plugin -n org/plugin-name --clean
deno task remove:plugin -n org/plugin-name --clean=development,production

Installing a plugin only copies its files and merges its import map - it does not run the plugin's setup script automatically. You must run setup explicitly (via --setup during add:plugin, or setup:plugin afterwards) for the plugin to create its database indexes, seed data, or run migrations.

When multiple plugins are installed, their routes and hooks all auto-merge:

/auth/*   → from thunder-core
/users/*  → from thunder-core
/stripe/* → from thunder-plugin-stripe
/email/*  → from thunder-plugin-email-sendgrid

Plugin Lifecycle Scripts (Setup & Cleanup)

Because Thunder is serverless-first, plugins must not create database indexes, seed data, or run migrations at app startup - that would slow cold starts and run on every instance. Instead, plugins ship lifecycle scripts that the consumer runs explicitly, once, against a chosen environment.

A plugin may include two optional files in its scripts/ folder:

  • scripts/setupPlugin.ts - runs on setup (--setup during install, or deno task setup:plugin). Put index creation, seeding, and migrations here.
  • scripts/cleanupPlugin.ts - runs on removal with cleanup (remove:plugin --clean). Put teardown logic here (drop collections, remove seeded data). Safe to omit.

When triggered, Thunder runs the script once per selected environment, spawning deno run -A <script> with ENV_TYPE set so it connects to the correct database.

Keep the real work in importable functions, then call them from the lifecycle entry point:

scripts/syncDBIndexes.ts
import { userModel } from "@/schemas/user.ts";
import { orderModel } from "@/schemas/order.ts";

export const syncDBIndexes = async () => {
  // Promise.allSettled so one failing index doesn't abort the rest.
  await Promise.allSettled([
    userModel.createIndex({ email: 1 }, { name: "email", unique: true, background: true }),
    orderModel.createIndex({ createdAt: -1 }, { name: "createdAt", background: true }),
  ]);
};

// Allow running directly: `deno run -A scripts/syncDBIndexes.ts`
if (import.meta.main) {
  await syncDBIndexes();
  Deno.exit();
}
scripts/setupPlugin.ts
import { syncDBIndexes } from "./syncDBIndexes.ts";
import { seedDefaults } from "./seedDefaults.ts";

await syncDBIndexes().catch(console.warn);
await seedDefaults().catch(console.warn);

Deno.exit();
scripts/cleanupPlugin.ts
console.log("Plugin uninstalled successfully!");

Setup scripts should be idempotent. createIndex is naturally idempotent (re-creating an existing index is a no-op), but inserting seed documents is not - guard seeds with unique indexes or upserts so re-running setup doesn't duplicate data or crash on unique-constraint violations.

The --setup / --clean / --envs values accept a comma-separated list of environments (development, production, test). The matching .env / .env.<environment> files are loaded for that run, so make sure DATABASE_URL (and any plugin env vars) are set for each environment you target. Document in your plugin's README/llms.txt exactly what the setup script does and which env vars it needs.


Getting Started: thunder-core

Thunder ships as a minimal framework. To access essential features, install the official core plugin:

deno task add:plugin -n Huruf-Tech/thunder-core

thunder-core provides:

  • Authentication (via better-auth)
  • Role-based access control (RBAC)
  • Security headers & CORS
  • User management admin panel
  • Session management
  • Built-in database migrations
  • Common utilities (withAuthSession, withTenant, withAuthGuard, etc.)

thunder-core is fully documented in this section. See the thunder-core docs for setup, authentication, multi-tenancy, RBAC, OAuth2, API keys, the wallet/ledger, and more.


Building a Plugin

Creating a reusable plugin is simple - it's just a Thunder project published to GitHub.

Naming Convention

Use the pattern thunder-plugin-{feature}:

  • thunder-plugin-stripe - Payment processing
  • thunder-plugin-email-sendgrid - Email service
  • thunder-plugin-sms-twilio - SMS service

Plugin Structure

A plugin is identical to any Thunder project:

serve.ts
database.ts
deno.json
.env.example
README.md
llms.txt

Best Practices

Use semantic route namespaces

Namespace routes by feature instead of using generic names like /api/*:

routes/stripe.ts
export default new Router("/api", function stripe(router) {
  router.post("/checkout", function checkout() { /* ... */ });
  router.get("/invoices", function listInvoices() { /* ... */ });
});
// Routes: /stripe/api/checkout, /stripe/api/invoices

Document environment variables

Always provide a .env.example listing required variables:

.env.example
# Stripe API keys (from https://stripe.com)
STRIPE_API_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...

Follow hook priority conventions

PriorityUse Case
100+Authentication / security (CORS, auth validation)
50-99Business logic (validation, enrichment)
0-49Optional hooks
-100Logging / cleanup

See the Hooks page for the full priority model.

Export types and version properly

Export TypeScript types so consumers get full type safety, and use semantic versioning (major.minor.patch).

Example: A Minimal Email Plugin

thunder-plugin-email/routes/email.ts
import { Router } from "@/core/http/router.ts";
import { bodyAsJson } from "@/core/http/utils.ts";
import { Response } from "@/core/http/response.ts";
import z from "zod";

export default new Router("/email", function email(router) {
  router.post("/send", function sendEmail() {
    const $body = z.object({
      to: z.string().email(),
      subject: z.string(),
      body: z.string(),
    });

    return {
      shape: () => ({ body: $body }),
      handler: async (req) => {
        const data = $body.parse(await bodyAsJson(req));
        // Send via your provider (Sendgrid, AWS SES, etc.)
        return Response.json({ success: true, messageId: "msg_123" });
      },
    };
  });
});

Publish it and consumers install with:

deno task add:plugin -n myorg/thunder-plugin-email

Limitations

No recursive dependencies. A plugin cannot depend on another plugin. Plugin A can be installed alone, and Plugin A + Plugin B can be installed together, but Plugin A depending on Plugin B is not supported. If your plugin needs another plugin's features, document it in the README and assume the consumer installs both.


On this page