Skip to content

Post Actions – astrojs-wp-integration

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),
};

src/pages/new-post.astro
---
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>

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
});
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',
});

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',
});

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 }

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 }

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.


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>.

src/actions/index.ts
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' }),
};

Register the post type in WordPress with show_in_rest and a rest_base, then pass the rest_base value as the resource:

src/actions/index.ts
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' });

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.

src/lib/product-schema.ts
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>;

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,
}),
};
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
}

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.

Map ACF choice values in action responses
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).

  • TypeScript88%
  • PHP11%
  • Astro1%