Post Actions – astrojs-wp-integration
Post Actions
Section titled “Post Actions”Post actions let you create, update, and delete WordPress posts from Astro server-side code — form handlers, API routes, middleware, or server components.
Register the three action factories in your Astro project’s src/actions/index.ts:
import { createCreatePostAction, createUpdatePostAction, createDeletePostAction,} from 'wp-astrojs-integration';import { WordPressClient } from 'fluent-wp-client';import { wordPressAuthBridge } from '../lib/auth/bridge';
export const server = { createPost: createCreatePostAction(wordPressAuthBridge.getClient), updatePost: createUpdatePostAction(wordPressAuthBridge.getClient), deletePost: createDeletePostAction(wordPressAuthBridge.getClient),};If you are not using JWT sessions, create one static client and pass that:
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),};Creating a post
Section titled “Creating a post”Draft post
Section titled “Draft post”---import { actions } from 'astro:actions';
if (Astro.request.method === 'POST') { const formData = await Astro.request.formData();
const { data, error } = await Astro.callAction(actions.createPost, { title: formData.get('title') as string, content: formData.get('content') as string, status: 'draft', });
if (data) { return Astro.redirect(`/posts/${data.slug}`); }}---
<form method="POST"> <input name="title" placeholder="Post title" required /> <textarea name="content" placeholder="Post content"></textarea> <button type="submit">Save draft</button></form>Published post
Section titled “Published post”Pass status: 'publish' to make the post live immediately:
const { data } = await Astro.callAction(actions.createPost, { title: 'Hello World', content: '<p>My first post.</p>', excerpt: 'A short summary.', status: 'publish', categories: [3], // category IDs tags: [12, 15], // tag IDs});All available fields
Section titled “All available fields”const { data } = await Astro.callAction(actions.createPost, { title: 'Full Example', content: '<p>Post body as raw HTML.</p>', excerpt: 'Short summary shown in listings.', status: 'publish', // 'publish' | 'draft' | 'pending' | 'private' | 'future' slug: 'full-example', date: '2026-03-01T10:00:00', // ISO 8601 — used for scheduled posts author: 1, // user ID featured_media: 42, // attachment ID categories: [3, 7], tags: [12], sticky: false, format: 'standard', // 'standard' | 'aside' | 'gallery' | 'video' | … comment_status: 'open', ping_status: 'closed',});Updating a post
Section titled “Updating a post”updatePost accepts the same fields as createPost plus the required id. Only the fields you provide are sent to WordPress — everything else is left unchanged.
const { data } = await Astro.callAction(actions.updatePost, { id: 101, title: 'Updated title', status: 'publish',});Deleting a post
Section titled “Deleting a post”Move to trash
Section titled “Move to trash”Omit force (or pass false) to move the post to the WordPress trash. The post can still be restored from the admin.
const { data } = await Astro.callAction(actions.deletePost, { id: 101 });// data → { id: 101, deleted: false }Permanent delete
Section titled “Permanent delete”Pass force: true to bypass the trash and permanently remove the post.
const { data } = await Astro.callAction(actions.deletePost, { id: 101, force: true });// data → { id: 101, deleted: true }Post meta
Section titled “Post meta”WordPress exposes registered meta fields through the REST API. Include them under the meta key:
// Meta fields must be registered in WordPress with show_in_rest: true://// register_post_meta('post', 'reading_time', [// 'show_in_rest' => true,// 'single' => true,// 'type' => 'integer',// ]);
const { data } = await Astro.callAction(actions.createPost, { title: 'Post with meta', content: '<p>Body.</p>', status: 'publish', meta: { reading_time: 5, source_url: 'https://example.com/original', },});Meta keys not registered with show_in_rest: true are silently ignored by WordPress. Zod does not validate meta key names or value types — see the Actions overview card above for the validation model details.
Pages and custom post types
Section titled “Pages and custom post types”All action factories accept a resource parameter that controls which REST endpoint is targeted. WordPress exposes pages at /wp-json/wp/v2/pages and custom post types at /wp-json/wp/v2/<rest_base>.
import { createCreatePostAction, createUpdatePostAction, createDeletePostAction,} 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 = { createPage: createCreatePostAction(wp, { resource: 'pages' }), updatePage: createUpdatePostAction(wp, { resource: 'pages' }), deletePage: createDeletePostAction(wp, { resource: 'pages' }),};Custom post types
Section titled “Custom post types”Register the post type in WordPress with show_in_rest and a rest_base, then pass the rest_base value as the resource:
export const server = { createProduct: createCreatePostAction(wp, { resource: 'products' }), updateProduct: createUpdatePostAction(wp, { resource: 'products' }), deleteProduct: createDeletePostAction(wp, { resource: 'products' }),};Using execute* directly for custom post types
Section titled “Using execute* directly for custom post types”The execute* functions also accept resource. This is useful when building fully custom actions or calling from non-Astro contexts:
import { WordPressClient } from 'fluent-wp-client';import { executeCreatePost, executeUpdatePost, executeDeletePost,} from 'wp-astrojs-integration/actions';
const wp = new WordPressClient({ baseUrl: import.meta.env.WP_URL, auth: { username: import.meta.env.WP_USERNAME, password: import.meta.env.WP_APP_PASSWORD, },});
const product = await executeCreatePost(wp, { title: 'Running Shoes', status: 'publish',}, { resource: 'products' });Custom post types with typed ACF fields
Section titled “Custom post types with typed ACF fields”When a custom post type surfaces ACF or other plugin fields via the REST API, extend the input schema to get end-to-end type safety through Zod.
Define an extended schema
Section titled “Define an extended schema”import { createPostInputSchema, updatePostInputSchema } from 'wp-astrojs-integration';import { z } from 'astro/zod';
const acfShape = z.object({ price: z.number().positive().optional(), sku: z.string().optional(), in_stock: z.boolean().optional(), gallery: z.array(z.number().int()).optional(), // attachment IDs}).optional();
export const productCreateSchema = createPostInputSchema.extend({ acf: acfShape });export const productUpdateSchema = updatePostInputSchema.extend({ acf: acfShape });
export type ProductCreateInput = z.infer<typeof productCreateSchema>;export type ProductUpdateInput = z.infer<typeof productUpdateSchema>;Use the extended schema with a factory
Section titled “Use the extended schema with a factory”The create*Action factories accept both schema (for typed input) and resource (for the REST endpoint). Combine them to get end-to-end type safety for a custom post type:
import { WordPressClient } from 'fluent-wp-client';import { createCreatePostAction, createUpdatePostAction } from 'wp-astrojs-integration';import { productCreateSchema, productUpdateSchema } from '../lib/product-schema';
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 = { createProduct: createCreatePostAction(wp, { resource: 'products', schema: productCreateSchema, }), updateProduct: createUpdatePostAction(wp, { resource: 'products', schema: productUpdateSchema, }),};Call the typed action
Section titled “Call the typed action”const { data, error } = await Astro.callAction(actions.createPost, { title: 'Running Shoes', status: 'publish', acf: { price: 129.99, sku: 'RS-42-BLK', in_stock: true, gallery: [55, 56, 57], },});
if (data) { console.log(data.id); // WordPress-assigned post ID}Map successful action responses
Section titled “Map successful action responses”Create and update actions return the WordPress object from the successful REST response. Use mapResponse when that object needs the same site-specific normalization as loader data, for example mapping ACF choice values to labels after submit.
type AcfChoice = { label: string; value: string;};
type AcfSchemaField = { choices?: AcfChoice[];};
async function getChoiceLabels(client: WordPressClient, resource: string) { const acfFields = await client.content(resource).getSchemaValue< Record<string, AcfSchemaField> >('properties.acf.properties');
const choiceLabels = new Map<string, Map<string, string>>();
for (const [fieldName, fieldSchema] of Object.entries(acfFields ?? {})) { if (!Array.isArray(fieldSchema.choices)) continue;
choiceLabels.set( fieldName, new Map(fieldSchema.choices.map((choice) => [choice.value, choice.label])), ); }
return choiceLabels;}
export const server = { updateProduct: createUpdatePostAction(wp, { resource: 'products', schema: productUpdateSchema, mapResponse: async (product, { client, resource }) => { const choiceLabels = await getChoiceLabels(client, resource);
return { ...product, acf: Object.fromEntries( Object.entries(product.acf ?? {}).map(([fieldName, value]) => [ fieldName, choiceLabels.get(fieldName)?.get(String(value)) ?? value, ]), ), }; }, }),};mapResponse runs before the response schema is validated and before the action returns data to Astro. The callback receives the resolved request-scoped WordPressClient, the resource, the submitted input, and the operation (create or update).