Litestore · Developer guide

Actions

Learn how admin and storefront writes are declared as zsa server actions, how permission procedures gate them, and how the CRUD layer generates the standard six.

Every write the admin and the storefront perform is a zsa server action living in server/{admin,web}/<domain>/actions.ts.

A file begins with "use server", and each export is a procedure that validates its input with Zod, checks a permission, and returns a typed result.

There is no separate API layer in front of them. The permission check and the input schema are part of the write itself rather than a gate someone could route around, which is why a write has one place to look rather than two.

The shape of an action

A domain builds its permission gate once, then hangs actions off it:

server/admin/tags/actions.ts
"use server"

import { z } from "zod"
import { AdminModule, modulePermission } from "~/lib/permissions/server"

const tagPermission = modulePermission(AdminModule.tags)

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

Reading that chain one link at a time:

  1. modulePermission(AdminModule.tags) returns a factory for the module.
  2. Calling it with an action name — "create", "update", "delete", and the rest — produces a zsa procedure that has already resolved the caller's permissions and rejected a banned account.
  3. .createServerAction() turns the procedure into an action builder.
  4. .input() takes a Zod schema.
  5. .handler() receives the parsed value.

The consequence of step 4 and step 5 together is that an invalid payload never reaches your code. The handler's argument is the parsed value, not the submitted one.

Building the factory once per domain is what keeps the permission string from being retyped per action, which is the kind of string that goes wrong quietly.

What the CRUD layer generates

A domain with a crud.ts gets its standard writes for free rather than writing them by hand.

An action like this is one line because tagCrud produces it:

server/admin/tags/actions.ts
export const permanentDeleteTags = tagCrud.permanentDeleteAction()

See The CRUD Layer for the six generated actions and the batch ceiling they enforce.

Hand-written actions are for the operations that fall outside create/read/update/archive/restore/delete.

Activity and cache

An action that changes something an operator would want to see recorded emits an activity entry. One that changes something cached invalidates its tags.

Both are explicit:

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

Actions generated by the CRUD layer invalidate through getRelatedCacheTags. So a hand-written action is the case where you own the invalidation — nothing upstream is doing it for you.

scripts/check-cache-invalidation.ts fails the build on a mutation that writes a cached model without invalidating, which is what keeps that from being forgotten.

A comment can explain why no invalidation is owed

Where a mutation deliberately skips invalidation, the reason is written next to it — the tag import notes that nothing reads the tags cache tag because tag lists are uncached queries. The check reads those annotations.

Keeping the surface honest

scripts/check-dead-server-actions.ts fails the build on an exported action with no caller.

Because the admin is the only consumer of these actions, an orphan is almost always a screen that was deleted without its server half.

That is a specific failure worth naming:

screen deleted

its actions.ts export left behind

nothing in the repo calls it

no error, no failing test

Nothing surfaces a leftover export on its own. The check is what makes it visible.

Where a new write goes

If the operation is create, update, archive, restore, duplicate or permanent delete, take the generated action from the module's crud.ts rather than writing it.

If it is anything else — a bulk import, a state transition, a recomputation — write it by hand in actions.ts, hung off the same modulePermission factory the rest of the domain uses.

If you write it by hand and it touches a cached model, you own the invalidation, and either the tags or a comment explaining their absence has to be there.

And if the screen that called it goes away, delete the action with it.

The question to ask of a new action is not "where does this code go?" It is:

Which of validation, permission, activity and invalidation is this write responsible for itself, and which does it inherit?

On this page