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,productionInstalling 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-sendgridPlugin 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 (--setupduring install, ordeno 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:
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();
}import { syncDBIndexes } from "./syncDBIndexes.ts";
import { seedDefaults } from "./seedDefaults.ts";
await syncDBIndexes().catch(console.warn);
await seedDefaults().catch(console.warn);
Deno.exit();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-corethunder-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 processingthunder-plugin-email-sendgrid- Email servicethunder-plugin-sms-twilio- SMS service
Plugin Structure
A plugin is identical to any Thunder project:
Best Practices
Use semantic route namespaces
Namespace routes by feature instead of using generic names like /api/*:
export default new Router("/api", function stripe(router) {
router.post("/checkout", function checkout() { /* ... */ });
router.get("/invoices", function listInvoices() { /* ... */ });
});
// Routes: /stripe/api/checkout, /stripe/api/invoicesDocument environment variables
Always provide a .env.example listing required variables:
# Stripe API keys (from https://stripe.com)
STRIPE_API_KEY=sk_test_...
STRIPE_WEBHOOK_SECRET=whsec_...Follow hook priority conventions
| Priority | Use Case |
|---|---|
100+ | Authentication / security (CORS, auth validation) |
50-99 | Business logic (validation, enrichment) |
0-49 | Optional hooks |
-100 | Logging / 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
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-emailLimitations
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.