Litestore · Developer guide
API

Actions

This page shows you how to 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. This page shows how those actions are written, so you can add one of your own or find the code behind an existing button.

Every action is a zsa server action in server/{admin,web}/<domain>/actions.ts. 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 want to know what a write does, you only have one place to look.

The shape of an action

Each domain builds its permission check once and then defines its actions on top of 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 }) => {
    // …
  })

Here is what each step in that chain does:

  1. modulePermission(AdminModule.tags) returns a factory for the module.
  2. 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.
  3. .createServerAction() turns the procedure into an action builder.
  4. .input() takes a Zod schema.
  5. .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 domain so that nobody retypes the permission string in each action. A mistyped permission string is the kind of bug that fails quietly.

What the CRUD layer generates

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

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

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

You write an action by hand only when the operation is something other than create, read, update, archive, restore or delete.

Activity and cache

When an action changes something an operator would want to see 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:

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 their cache through getRelatedCacheTags. In a hand-written action, the invalidation is your job. Nothing else in the chain does it for you.

scripts/check-cache-invalidation.ts fails the build when a mutation writes a cached model without invalidating it. That check makes sure nobody forgets.

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. The check reads these comments.

Removing actions nobody calls

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

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 else would point out the leftover export. The check is what makes it visible.

Where a new write goes

If the operation is create, update, archive, restore, duplicate or permanent delete, use the generated action from the module's crud.ts instead of writing one.

For anything else, such as a bulk import, a state change or a recalculation, write the action by hand in actions.ts. Build it on the same modulePermission factory that the rest of the domain uses.

If your hand-written action touches a cached model, you have to invalidate it. Either the tags are there, or a comment explains why they aren't.

If the screen that calls an action goes away, delete the action too.

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