Astro is a modern site builder with web best practices, performance, and DX front-of-mind.
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
Pending
The risk profile of this skill
Server Actions provide a type-safe way to define and call server-side functions from client code. Actions handle form submissions, validate input with Zod schemas, and provide automatic error handling with type-safe results.
Defines a server-side action with input validation and type-safe handling.
/**
* Defines a server-side action
* @param config - Action configuration
* @returns Action client function with type-safe input/output
*/
function defineAction<
TOutput,
TAccept extends ActionAccept | undefined = undefined,
TInputSchema extends z.ZodType | undefined = undefined
>(config: {
/** Input validation schema (Zod) */
input?: TInputSchema;
/** Accept type: 'form' for FormData or 'json' for JSON */
accept?: TAccept;
/** Handler function receiving validated input and context */
handler: ActionHandler<TInputSchema, TOutput>;
}): ActionClient<TOutput, TAccept, TInputSchema> & string;
type ActionAccept = 'form' | 'json';
type ActionHandler<TInputSchema, TOutput> =
TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContext) => MaybePromise<TOutput>
: (input: any, context: ActionAPIContext) => MaybePromise<TOutput>;Usage Examples:
// src/actions/index.ts
import { defineAction } from 'astro:actions';
import { z } from 'astro:content';
// Form action with validation
export const server = {
newsletter: defineAction({
accept: 'form',
input: z.object({
email: z.string().email(),
name: z.string().min(2),
}),
handler: async (input, context) => {
// input is automatically validated and typed
await saveToDatabase(input.email, input.name);
return { success: true };
},
}),
// JSON action
updateProfile: defineAction({
accept: 'json',
input: z.object({
userId: z.string(),
bio: z.string().max(500),
}),
handler: async (input, context) => {
const user = await updateUser(input.userId, { bio: input.bio });
return { user };
},
}),
// Action without input validation
getCurrentUser: defineAction({
handler: async (input, context) => {
const userId = context.cookies.get('userId');
return await fetchUser(userId);
},
}),
};Client-side usage:
---
import { actions } from 'astro:actions';
---
<!-- Form action -->
<form method="POST" action={actions.newsletter}>
<input type="email" name="email" required />
<input type="text" name="name" required />
<button>Subscribe</button>
</form>
<!-- Programmatic call -->
<script>
import { actions } from 'astro:actions';
const result = await actions.updateProfile({
userId: '123',
bio: 'Hello world',
});
if (result.error) {
console.error(result.error);
} else {
console.log(result.data);
}
</script>Accesses action request information from middleware.
/**
* Gets action context from middleware
* Provides information about action requests and result handling
* @param context - API context from middleware
* @returns Action context with action info and serialization utilities
*/
function getActionContext(context: APIContext): AstroActionContext;
interface AstroActionContext {
/** Information about the incoming action request (undefined if not an action) */
action?: {
/** How the action was called: 'rpc' (programmatic) or 'form' (HTML form) */
calledFrom: 'rpc' | 'form';
/** Name of the action being called */
name: string;
/** Function to programmatically call the action and get the result */
handler: () => Promise<SafeResult<any, any>>;
};
/**
* Manually set the action result for templates
* Disables Astro's automatic result handling
*/
setActionResult(actionName: string, actionResult: SerializedActionResult): void;
/** Serialize an action result for storage in cookies or sessions */
serializeActionResult: typeof serializeActionResult;
/** Deserialize an action result to access data and error objects */
deserializeActionResult: typeof deserializeActionResult;
}// src/middleware.ts
import { defineMiddleware } from 'astro:middleware';
import { getActionContext } from 'astro:actions';
export const onRequest = defineMiddleware(async (context, next) => {
const actionContext = getActionContext(context);
if (actionContext.action) {
console.log(`Action called: ${actionContext.action.name}`);
console.log(`Called from: ${actionContext.action.calledFrom}`);
// Optionally handle the action manually
const result = await actionContext.action.handler();
if (result.error) {
// Custom error handling
return new Response('Action failed', { status: 500 });
}
// Set custom action result
const serialized = actionContext.serializeActionResult(result);
actionContext.setActionResult(actionContext.action.name, serialized);
}
return next();
});Converts FormData to a JavaScript object based on a Zod schema.
/**
* Transforms FormData to an object based on Zod schema
* Handles type coercion for numbers, booleans, and arrays
* @param formData - FormData to convert
* @param schema - Zod object schema defining the structure
* @returns Object with typed values
*/
function formDataToObject<T extends z.AnyZodObject>(
formData: FormData,
schema: T
): Record<string, unknown>;import { formDataToObject } from 'astro:actions';
import { z } from 'astro:content';
const schema = z.object({
name: z.string(),
age: z.number(),
active: z.boolean(),
tags: z.array(z.string()),
});
const formData = new FormData();
formData.append('name', 'Alice');
formData.append('age', '25');
formData.append('active', 'true');
formData.append('tags', 'javascript');
formData.append('tags', 'typescript');
const obj = formDataToObject(formData, schema);
// { name: 'Alice', age: 25, active: true, tags: ['javascript', 'typescript'] }Base error class for action errors with HTTP status codes.
/**
* Error class for action failures
* Includes HTTP status code and type information
*/
class ActionError<T extends ErrorInferenceObject = ErrorInferenceObject> extends Error {
type: 'AstroActionError';
code: ActionErrorCode;
status: number;
constructor(params: {
/** Error message */
message?: string;
/** HTTP-based error code */
code: ActionErrorCode;
/** Optional stack trace */
stack?: string;
});
/** Convert error code to HTTP status */
static codeToStatus(code: ActionErrorCode): number;
/** Convert HTTP status to error code */
static statusToCode(status: number): ActionErrorCode;
/** Create error from JSON response */
static fromJson(body: any): ActionError;
}
/** HTTP-based error codes (e.g., 'BAD_REQUEST', 'UNAUTHORIZED', etc.) */
type ActionErrorCode =
| 'BAD_REQUEST'
| 'UNAUTHORIZED'
| 'PAYMENT_REQUIRED'
| 'FORBIDDEN'
| 'NOT_FOUND'
| 'METHOD_NOT_ALLOWED'
| 'NOT_ACCEPTABLE'
| 'PROXY_AUTHENTICATION_REQUIRED'
| 'REQUEST_TIMEOUT'
| 'CONFLICT'
| 'GONE'
| 'LENGTH_REQUIRED'
| 'PRECONDITION_FAILED'
| 'CONTENT_TOO_LARGE'
| 'URI_TOO_LONG'
| 'UNSUPPORTED_MEDIA_TYPE'
| 'RANGE_NOT_SATISFIABLE'
| 'EXPECTATION_FAILED'
| 'MISDIRECTED_REQUEST'
| 'UNPROCESSABLE_CONTENT'
| 'LOCKED'
| 'FAILED_DEPENDENCY'
| 'TOO_EARLY'
| 'UPGRADE_REQUIRED'
| 'PRECONDITION_REQUIRED'
| 'TOO_MANY_REQUESTS'
| 'REQUEST_HEADER_FIELDS_TOO_LARGE'
| 'UNAVAILABLE_FOR_LEGAL_REASONS'
| 'INTERNAL_SERVER_ERROR'
| 'NOT_IMPLEMENTED'
| 'BAD_GATEWAY'
| 'SERVICE_UNAVAILABLE'
| 'GATEWAY_TIMEOUT'
| 'HTTP_VERSION_NOT_SUPPORTED'
| 'VARIANT_ALSO_NEGOTIATES'
| 'INSUFFICIENT_STORAGE'
| 'LOOP_DETECTED'
| 'NETWORK_AUTHENTICATION_REQUIRED';
/** Array of all valid action error codes */
const ACTION_ERROR_CODES: readonly ActionErrorCode[];import { defineAction, ActionError } from 'astro:actions';
export const server = {
deletePost: defineAction({
input: z.object({ postId: z.string() }),
handler: async (input, context) => {
const user = context.locals.user;
if (!user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'You must be logged in',
});
}
const post = await getPost(input.postId);
if (!post) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Post not found',
});
}
if (post.authorId !== user.id) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'You can only delete your own posts',
});
}
await deletePost(input.postId);
return { success: true };
},
}),
};Error class for input validation failures (Zod schema validation).
/**
* Error class for input validation failures
* Extends ActionError with Zod validation issues
*/
class ActionInputError<T extends ErrorInferenceObject> extends ActionError {
type: 'AstroActionInputError';
/** Array of Zod validation issues */
issues: z.ZodIssue[];
/** Field-specific error messages */
fields: Record<string, string[]>;
constructor(issues: z.ZodIssue[]);
}<script>
import { actions, isInputError } from 'astro:actions';
const result = await actions.newsletter({
email: 'invalid-email',
name: 'A',
});
if (result.error) {
if (isInputError(result.error)) {
// Validation error
console.log(result.error.fields.email); // ["Invalid email"]
console.log(result.error.fields.name); // ["String must contain at least 2 characters"]
console.log(result.error.issues); // Full Zod issues array
} else {
// Other action error
console.log(result.error.message);
}
}
</script>Type guard to check if an error is an ActionError.
/**
* Type guard for ActionError
* @param error - Error to check
* @returns True if error is an ActionError (includes ActionInputError)
*/
function isActionError(error?: unknown): error is ActionError;import { actions, isActionError } from 'astro:actions';
try {
const result = await actions.updateProfile({ bio: 'New bio' });
} catch (error) {
if (isActionError(error)) {
// error is ActionError or ActionInputError
console.log('Action failed:', error.code, error.message);
} else {
// Unknown error type
console.error('Unexpected error:', error);
}
}Type guard to check if an error is an ActionInputError specifically.
/**
* Type guard for ActionInputError (input validation errors)
* @param error - Error to check
* @returns True if error is ActionInputError
*/
function isInputError<T extends ErrorInferenceObject>(
error?: ActionError<T>
): error is ActionInputError<T>;import { actions, isInputError } from 'astro:actions';
const result = await actions.newsletter.safe({ email: 'invalid' });
if (!result.data) {
if (isInputError(result.error)) {
// Validation error - show field-specific errors
console.log('Validation failed:', result.error.fields);
} else {
// Other action error
console.log('Action error:', result.error.message);
}
}Serializes an action result for storage or transmission.
/**
* Serializes action result to a storable format
* Used internally and for custom result handling in middleware
* @param result - Safe result with data or error
* @returns Serialized result
*/
function serializeActionResult(
result: SafeResult<any, any>
): SerializedActionResult;
type SerializedActionResult =
| {
type: 'data';
contentType: 'application/json+devalue';
status: 200;
body: string;
}
| {
type: 'error';
contentType: 'application/json';
status: number;
body: string;
}
| {
type: 'empty';
status: 204;
};Deserializes an action result from storage or transmission.
/**
* Deserializes action result from serialized format
* @param result - Serialized action result
* @returns Safe result with data or error
*/
function deserializeActionResult(
result: SerializedActionResult
): SafeResult<any, any>;Wraps a handler call with automatic error handling.
/**
* Executes a handler with automatic error catching
* Converts thrown errors to ActionError instances
* @param handler - Handler function to execute
* @returns Safe result with data or error
*/
function callSafely<TOutput>(
handler: () => MaybePromise<TOutput>
): Promise<SafeResult<z.ZodType, TOutput>>;Type-safe result wrapper for action responses.
/**
* Type-safe result wrapper
* Either contains data or error, never both
*/
type SafeResult<TInput extends ErrorInferenceObject, TOutput> =
| {
data: TOutput;
error: undefined;
}
| {
data: undefined;
error: ActionError<TInput>;
};Type-safe client interface for calling actions.
/**
* Client-side action caller with type safety
* Provides both safe calls (returns SafeResult) and orThrow variant
*/
type ActionClient<
TOutput,
TAccept extends ActionAccept | undefined,
TInputSchema extends z.ZodType | undefined
> = ((input: /* inferred from schema */) => Promise<SafeResult</* inferred */, TOutput>>) & {
/** Query string for form actions */
queryString: string;
/** Throws errors instead of returning SafeResult */
orThrow: (input: /* inferred from schema */) => Promise<TOutput>;
};Context object passed to action handlers.
/**
* Context passed to action handlers
* Extends APIContext with action-specific properties
*/
type ActionAPIContext = Omit<APIContext, 'props' | 'getActionResult' | 'callAction' | 'redirect'>;Utility type to extract return type from action handler.
/**
* Extracts return type from action handler
* Useful for typing action results
*/
type ActionReturnType<T extends ActionHandler<any, any>> = Awaited<ReturnType<T>>;The actions proxy provides access to all defined server actions with type safety.
/**
* Proxy object for accessing defined actions
* Provides type-safe access to all actions defined in src/actions/index.ts
*/
const actions: ActionProxy;import { actions } from 'astro:actions';
// Call actions programmatically
const result = await actions.newsletter({
email: 'user@example.com',
name: 'John Doe',
});
// Use with forms
<form method="POST" action={actions.newsletter}>
<input type="email" name="email" />
<button>Subscribe</button>
</form>
// Access nested actions
const result = await actions.user.profile.update({ bio: 'Hello' });
// Throw on error instead of returning SafeResult
const data = await actions.newsletter.orThrow({
email: 'user@example.com',
name: 'John Doe',
});Gets the URL path for an action, useful for manual fetch requests or custom integrations.
/**
* Gets the URL path for an action
* @param action - Action client function
* @returns Action URL path including base URL
*/
function getActionPath(action: ActionClient<any, any, any>): string;import { actions, getActionPath } from 'astro:actions';
// Get the action's URL path
const path = getActionPath(actions.newsletter);
// Returns: '/_actions/newsletter' (with base URL and trailing slash if configured)
// Use for custom fetch
const response = await fetch(path, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: 'user@example.com', name: 'John' }),
});Generates a query string for a named action, useful for constructing action URLs manually.
/**
* Generates query string for an action by name
* @param name - Action name (e.g., 'newsletter' or 'user.profile.update')
* @returns Query string with action parameter (e.g., '?_astroAction=newsletter')
*/
function getActionQueryString(name: string): string;import { getActionQueryString } from 'astro:actions';
// Get query string for action
const qs = getActionQueryString('newsletter');
// Returns: '?_astroAction=newsletter'
// Construct full action URL
const actionUrl = `/_actions/newsletter${qs}`;
// Useful for custom routing or integration scenarios
const response = await fetch(actionUrl, {
method: 'POST',
body: formData,
});All server action APIs are available from the astro:actions virtual module:
import {
actions,
defineAction,
getActionContext,
getActionPath,
formDataToObject,
ActionError,
ActionInputError,
isInputError,
callSafely,
serializeActionResult,
deserializeActionResult,
type ActionAccept,
type ActionHandler,
type ActionClient,
type ActionErrorCode,
type SafeResult,
type SerializedActionResult,
type AstroActionContext,
type ActionAPIContext,
type ActionReturnType,
} from 'astro:actions';The astro/actions/runtime/server and astro/actions/runtime/client modules also export these same APIs but are typically not imported directly.