API

Generate the full posts API with createCRUD - tenant isolation, an afterCreate hook, and a custom soft-delete.

This single createCRUD call generates the full posts API, scopes it per tenant, fires a notification on creation, and hands the del endpoint over to our own handler.

routes/posts.ts
import { Router } from "@/core/http/router.ts";
import { createCRUD } from "@/core/utils/createCRUD.ts";
import { paramsAsJson } from "@/core/http/utils.ts";
import { Response } from "@/core/http/response.ts";
import { postInputSchema, postModel, postSchema } from "@/schemas/cms/post.ts";
import { withTenant } from "@plugins/Huruf-Tech/thunder-core/utils/withTenant.ts";
import { ObjectId } from "mongodb";
import z from "zod";

import { notifyNewPost } from "@/lib/notifications.ts";

export default new Router("/api", function posts(router) {
  createCRUD(
    {
      router,
      schema: postSchema,
      insertSchema: postInputSchema,
      model: postModel,
    },
    {
      // Public reads, tenant-scoped writes
      isolationFields: async (req, action) => {
        if (["get", "count"].includes(action)) return {}; // allow anyone to read/count

        const { tenant } = await withTenant(req);

        return {
          tenant: tenant._id,
        };
      },

      // Notify subscribers whenever a post is created
      hooks: {
        afterCreate: async ({ data }) => {
          await notifyNewPost(data);
        },
      },

      // We'll replace the default hard-delete with a soft-delete below
      disable: { del: true },
    },
  );

  // Custom replacement for DELETE /posts/api/:id -> soft-delete
  router.del("/:id", function archivePost() {
    const $params = z.object({ id: z.string() });
    const $return = z.object({ archived: z.boolean() });

    return {
      shape: () => ({ params: $params, return: $return }),
      handler: async (req) => {
        const { id } = $params.parse(paramsAsJson(req));
        const { tenant } = await withTenant(req);

        await postModel.updateOne(
          { _id: new ObjectId(id), tenant: tenant._id },
          { $set: { deletedAt: new Date() } },
        );

        return Response.ok({ archived: true } satisfies z.output<typeof $return>);
      },
    };
  });
}).group("CMS");

That's the whole feature. The rest of this page breaks down each moving part.


Isolation Fields = multi-tenancy for free

isolationFields runs for every CRUD action and returns the fields used to scope that operation:

  • For get and count we return {}, so reads are public - any visitor can browse posts.
  • For every other action (create, update, del) we resolve the current tenant with withTenant(req) and return { tenant: tenant._id }.

On create, that field is written into the document. On update, it's added to the query filter - so a tenant can only ever modify their own posts. This is the entire multi-tenancy story, with no manual filtering in your handlers.

withTenant comes from thunder-core and reads the authenticated session to determine the active tenant. If the request isn't authenticated, it throws - which is exactly why returning {} for get/count keeps those endpoints open to the public.

afterCreate lifecycle hook

The hooks.afterCreate callback receives the freshly inserted data and runs after the record is persisted. Here we call notifyNewPost(data) to alert subscribers.

lib/notifications.ts
import type { z } from "zod";
import type { postSchema } from "@/schemas/cms/post.ts";

export async function notifyNewPost(post: z.infer<typeof postSchema>) {
  // Send an email, push a webhook, fan out to subscribers, etc.
  console.log(`New post published: ${post.title}`);
}

Keep lifecycle hooks fast. Thunder is serverless-first, so for heavy work (sending thousands of emails, calling slow third-party APIs) enqueue a job and let a separate worker process it instead of blocking the request.

Disabling a route and replacing it

disable: { del: true } removes the generated hard-delete endpoint. We then define our own router.del("/:id", ...) on the same router, which mounts at the exact same path (DELETE /posts/api/:id) but soft-deletes by stamping deletedAt instead of removing the document.

Notice the custom handler also scopes its query by tenant._id, mirroring what isolationFields does automatically for the generated routes - so the tenancy guarantee holds even in your custom code.

This disable-and-replace pattern is the key to createCRUD's flexibility: keep the generated routes you want, and hand-roll the ones that need special behaviour.

With the API complete, let's review it through the generated OpenAPI spec.


On this page