Litestore · Developer docs
API

Actions

Write a server action that changes data from the admin or the storefront.

When the admin or the storefront changes data, it calls a server action. The sections below show how those actions are written, both for adding your own and for finding the code behind an existing button.

Request

GET/api/products?q=linen&limit=12
AccessNo key needed
NotesDescribed by /api/openapi.json
A button in the admin calls a server action, which checks the permission and parses the input before the handler runs.

File organization

Every action is a zsa server action in a module's admin/ or storefront/ folder, such as modules/orders/admin/actions.ts. "use server" files live nowhere else. The file begins with "use server". Each export validates its input with Zod, checks a permission and returns a typed result.

There is no separate API layer in front of the actions. The permission check and the input schema are part of the write itself, so nobody can route around them. When you need to find the permission check, validation and database change for a write, you only have one place to look.

Action anatomy

Each module builds its permission check once and then defines its actions on top of it:

modules/products/admin/tag-actions.ts
"use server"

import { z } from "zod"
import { modulePermission } from "~/platform/permissions/procedures"
import { AdminModule } from "~/platform/permissions/types"

const tagPermission = modulePermission(AdminModule.tags)

export const bulkImportTags = tagPermission("create")
  .createServerAction()
  .input(z.object({ slugs: z.array(z.string().min(1)) }))
  .handler(async ({ input }) => {
    // …
  })

Permission factory

modulePermission(AdminModule.tags) returns a factory for the module.

Action name

You call the factory with an action name such as "create", "update" or "delete". It gives back a zsa procedure that has already loaded the caller's permissions and turned away banned accounts.

Action builder

.createServerAction() turns the procedure into an action builder.

Input schema

.input() takes a Zod schema.

Handler

.handler() receives the parsed value.

Because of steps 4 and 5, an invalid payload never reaches your code. Your handler gets the parsed value, not what the browser sent.

You build the factory once per module so that nobody retypes the permission string in each action. A mistyped permission string is the kind of bug that fails quietly.

Generated actions

If a domain has a crud.ts, you don't write its archive, restore and permanent-delete actions by hand. The CRUD object generates them, so an action like this takes one line:

modules/products/admin/tag-actions.ts
export const permanentDeleteTags = tagCrud.permanentDeleteAction()

The CRUD Layer lists the three generated actions and the batch limit they enforce.

Create and update actions are written by hand on the module's permission factory and call crud.create or crud.update, so the schema and permission are visible at the call site.

Activity and cache

When an action changes something an operator would look for in the history, it emits an activity entry. When it changes something that is cached, it invalidates the matching cache tags.

You write both of these out explicitly:

modules/products/admin/tag-actions.ts
void emitActivity({
  type: "tags_imported",
  description: `Imported ${created} tags`,
  module: "tag",
  operation: "other",
  metadata: { count: created, skipped },
})

Writes that go through the CRUD layer refresh the module's cacheTags themselves. In a hand-written action that writes with Prisma directly, the invalidation is your job. Nothing else in the chain invalidates the cache.

A comment can explain why no invalidation is needed

When a mutation skips invalidation on purpose, write the reason next to it. The tag import, for example, notes that nothing reads the tags cache tag because tag lists are uncached queries. That way a reviewer can tell the omission is intentional.

Unused actions

Delete an exported action when nothing calls it.

The admin is the only code that calls these actions. So an action with no caller almost always means someone deleted a screen and left its server code behind.

This is how that happens without anyone noticing:

screen deleted
    ↓
its actions.ts export left behind
    ↓
nothing in the repo calls it
    ↓
no error, no failing test

Nothing points out the leftover export automatically; a grep for its callers is what makes it visible.

Adding writes

Before you write a new action, ask yourself which of these jobs the action does itself and which it inherits:

Does this write handle its own validation, permission check, activity entry and cache invalidation, or does something upstream do them?

On this page