Actions – astrojs-wp-integration
Actions are Astro server actions that wrap the WordPress REST API, providing type-safe authenticated operations from any server-side Astro context.
Every action factory works directly with a WordPressClient or a request-aware client resolver such as wordPressAuthBridge.getClient. Under the hood, each action delegates to fluent-wp-client and maps failures to ActionError.
Integration testing
Section titled “Integration testing”Action integration tests run through a real Astro fixture app and call /_actions/* endpoints over HTTP.
This validates routing, input parsing, handler execution, and serialized responses as one end-to-end flow.
Use npm run test:integration for action/loaders/auth integration checks through the real Astro fixture app.
Authentication
Section titled “Authentication”All write operations use one WordPressClient or one request-aware client resolver.
Client-first (recommended)
Section titled “Client-first (recommended)”import { createCreatePostAction, createCreateTermAction, createCreateUserAction, createDeletePostAction, createGetAbilityAction, createUpdatePostAction,} from 'wp-astrojs-integration';import { wordPressAuthBridge } from '../lib/auth/bridge';
export const server = { createPost: createCreatePostAction(wordPressAuthBridge.getClient), updatePost: createUpdatePostAction(wordPressAuthBridge.getClient), deletePost: createDeletePostAction(wordPressAuthBridge.getClient), createCategory: createCreateTermAction(wordPressAuthBridge.getClient, { resource: 'categories' }), createUser: createCreateUserAction(wordPressAuthBridge.getClient), getAbility: createGetAbilityAction(wordPressAuthBridge.getClient),};Static client (app password)
Section titled “Static client (app password)”import { WordPressClient } from 'fluent-wp-client';
const wp = new WordPressClient({ baseUrl: import.meta.env.WP_URL, auth: { username: import.meta.env.WP_USERNAME, password: import.meta.env.WP_APP_PASSWORD, },});
export const server = { createPost: createCreatePostAction(wp), updatePost: createUpdatePostAction(wp),};Static client (JWT)
Section titled “Static client (JWT)”import { WordPressClient } from 'fluent-wp-client';
const wp = new WordPressClient({ baseUrl: import.meta.env.WP_URL, auth: { token: import.meta.env.WP_JWT_TOKEN, },});Static client with request-aware auth headers
Section titled “Static client with request-aware auth headers”import { WordPressClient } from 'fluent-wp-client';
const wp = new WordPressClient({ baseUrl: import.meta.env.WP_URL, authHeaders: ({ method, url, body }) => { // Replace with your signer implementation const signature = createSignedAuthHeader({ method, url: url.toString(), body });
return { Authorization: signature, }; },});createSignedAuthHeader is a user-defined function for custom auth integrations. It is not part of this package and does not perform a network request by itself.
Use the same client in all factories when you want one shared static auth setup:
import { createCreatePostAction, createUpdatePostAction, createDeletePostAction, createCreateTermAction, createUpdateTermAction, createDeleteTermAction, createCreateUserAction, createUpdateUserAction, createDeleteUserAction, createGetAbilityAction, createRunAbilityAction,} from 'wp-astrojs-integration';import { WordPressClient } from 'fluent-wp-client';
const wp = new WordPressClient({ baseUrl: import.meta.env.WP_URL, auth: { username: import.meta.env.WP_USERNAME, password: import.meta.env.WP_APP_PASSWORD, },});
export const server = { createPost: createCreatePostAction(wp), updatePost: createUpdatePostAction(wp), deletePost: createDeletePostAction(wp), createCategory: createCreateTermAction(wp, { resource: 'categories' }), updateTag: createUpdateTermAction(wp, { resource: 'tags' }), deleteGenre: createDeleteTermAction(wp, { resource: 'genres' }), createUser: createCreateUserAction(wp), updateUser: createUpdateUserAction(wp), deleteUser: createDeleteUserAction(wp), getAbility: createGetAbilityAction(wp), runAbility: createRunAbilityAction(wp), deleteAbility: createDeleteAbilityAction(wp),};Validation model
Section titled “Validation model”Input validation happens in two distinct layers. Understanding where each field is checked helps you handle errors correctly.
Layer 1 — Zod (local, before the HTTP request)
Section titled “Layer 1 — Zod (local, before the HTTP request)”These constraints are enforced by the Zod schema before any request reaches WordPress. A violation throws immediately in the Astro server process and never touches the network.
| Field | Constraint |
|---|---|
id (update / delete) | Must be a positive integer |
force (delete) | Must be a boolean |
status | Must be one of 'publish', 'draft', 'pending', 'private', 'future' |
comment_status | Must be a string (type only — not all post types support comments) |
ping_status | Must be a string (type only — not all post types support pingbacks) |
format | Must be a string (type only — only the built-in post type supports formats) |
sticky | Must be a boolean |
password | Must be a string |
author | Must be an integer (type only — existence is not checked) |
featured_media | Must be an integer (type only — existence is not checked) |
categories | Must be an array of integers (type only — term existence is not checked) |
tags | Must be an array of integers (type only — term existence is not checked) |
parent | Must be an integer (type only — used by hierarchical types like pages) |
menu_order | Must be an integer (type only — used by hierarchical types like pages) |
title, content, excerpt | Must be strings |
date, date_gmt | Must be strings (format is not validated — see Layer 2) |
slug, template | Must be strings (content is not validated — see Layer 2) |
meta | Must be a plain object (keys and value types are not validated) |
Layer 2 — WordPress REST API (remote, after the HTTP request)
Section titled “Layer 2 — WordPress REST API (remote, after the HTTP request)”These fields are forwarded to WordPress without further local inspection. WordPress validates them server-side and returns a structured error if they are invalid. The action catches that response and surfaces it as an ActionError.
| Field | What WordPress checks |
|---|---|
author | The user ID must exist and the authenticated user must be allowed to assign that author |
featured_media | The attachment ID must exist and be of media type image |
categories | Each ID must correspond to a published category term |
tags | Each ID must correspond to a published tag term |
date / date_gmt | The string must be a valid ISO 8601 date-time |
slug | Accepted as-is; WordPress appends a suffix (e.g. -2) if it conflicts with an existing post |
template | The filename must match a template registered by the active theme |
meta keys | Only keys registered with show_in_rest: true are accepted; unregistered keys are silently ignored |
Extra fields via .passthrough()
Section titled “Extra fields via .passthrough()”The base schema uses .passthrough(), meaning any extra key you include is forwarded to WordPress without Zod touching it. This is intentional — it allows ACF fields, custom REST fields, and other plugin data to pass through end-to-end.
Extend the schema with .extend() if you want Zod to validate those extra fields locally too:
import { createPostInputSchema } from 'wp-astrojs-integration';import { z } from 'astro/zod';
const mySchema = createPostInputSchema.extend({ acf: z.object({ price: z.number().positive(), sku: z.string(), }).optional(),});Use mapResponse on create/update post actions when the successful WordPress response needs site-specific normalization before Astro receives it, such as replacing ACF choice values with labels from discovery metadata.
See Mapping WordPress Values for a reusable callback pattern that works with both loaders and actions.
Error handling
Section titled “Error handling”All actions surface failures as ActionError. Use the error field returned by Astro.callAction:
const { data, error } = await Astro.callAction(actions.createPost, { title: 'My Post', status: 'publish',});
if (error) { // error.code — HTTP status mapped to an Astro error code string // error.message — original WordPress error message, passed through verbatim console.error(error.code, error.message);}The message is taken directly from the WordPress { code, message, data } error response and not modified. This means it reflects exactly what WordPress rejected and why.
Common error codes
Section titled “Common error codes”error.code | HTTP status | Typical cause |
|---|---|---|
UNAUTHORIZED | 401 | Missing, wrong, or expired credentials (basic auth or JWT) |
FORBIDDEN | 403 | Authenticated user lacks capability for the operation |
NOT_FOUND | 404 | Post / term / attachment ID does not exist |
UNPROCESSABLE_CONTENT | 422 | Invalid field value (bad date format, unknown template, etc.) |
BAD_REQUEST | 400 | Malformed request body |
INTERNAL_SERVER_ERROR | 500 | WordPress or PHP error on the server |
Zod validation failures (Layer 1) are surfaced as
BAD_REQUESTbefore the request is sent.
The execute* pattern
Section titled “The execute* pattern”Every action file exports two things:
create*Action(client)— the AstroActionClientfactory for use insrc/actions/index.ts.execute*(client, input, options?)— the raw async helper, useful for testing or building custom actions that target a different REST resource (e.g. a custom post type).
import { WordPressClient } from 'fluent-wp-client';import { executeCreatePost } from 'wp-astrojs-integration/actions';
const wp = new WordPressClient({ baseUrl: 'https://example.com', auth: { username, password },});
const product = await executeCreatePost( wp, { title: 'My Product', status: 'publish' }, { resource: 'products' },);If you use JWT, build the client with auth: { token } instead.
When resource is omitted it defaults to 'posts' for content actions and 'categories' for term actions.
This is also how integration tests call actions directly — without needing the Astro runtime.