Actions are type-safe server functions that handle form submissions, API calls, and other backend operations. They provide automatic validation, error handling, and progressive enhancement for forms.
Defining Actions
Create actions in src/actions/index.ts by exporting a server object:
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
subscribe: defineAction ({
input: z . object ({
email: z . string (). email ()
}),
handler : async ({ email }) => {
// Your server logic here
await db . subscribers . create ({ email });
return {
success: true ,
message: 'Subscribed successfully!'
};
}
})
};
Actions run exclusively on the server. Client code cannot see or execute handler logic directly.
Call actions from forms with automatic progressive enhancement:
src/pages/newsletter.astro
---
import { actions } from 'astro:actions' ;
const result = Astro . getActionResult ( actions . subscribe );
---
< html >
< body >
< form method = "POST" action = { actions . subscribe } >
< input type = "email" name = "email" required />
< button type = "submit" > Subscribe </ button >
</ form >
{ result ?. success && < p > { result . data . message } </ p > }
{ result ?. error && < p > Error: { result . error . message } </ p > }
</ body >
</ html >
Forms work without JavaScript but get enhanced with client-side validation and handling when JS is available.
Use Zod schemas to validate input automatically:
import { defineAction , ActionError } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
createPost: defineAction ({
input: z . object ({
title: z . string (). min ( 3 ). max ( 100 ),
content: z . string (). min ( 10 ),
tags: z . array ( z . string ()). optional (),
published: z . boolean (). default ( false )
}),
handler : async ({ title , content , tags , published }) => {
// Input is validated and typed
const post = await db . posts . create ({
title ,
content ,
tags: tags ?? [],
published
});
return { id: post . id };
}
})
};
Validation Errors
Invalid input automatically returns typed errors:
src/pages/create-post.astro
---
import { actions } from 'astro:actions' ;
const result = Astro . getActionResult ( actions . createPost );
---
< form method = "POST" action = { actions . createPost } >
< input name = "title" required />
{ result ?. error ?. fields ?. title && (
< p class = "error" > { result . error . fields . title } </ p >
) }
< textarea name = "content" required ></ textarea >
{ result ?. error ?. fields ?. content && (
< p class = "error" > { result . error . fields . content } </ p >
) }
< button type = "submit" > Create Post </ button >
</ form >
For file uploads and complex forms, use accept: 'form':
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
uploadImage: defineAction ({
accept: 'form' ,
input: z . object ({
image: z . instanceof ( File ),
caption: z . string (). optional ()
}),
handler : async ({ image , caption }) => {
// Handle file upload
const buffer = await image . arrayBuffer ();
const url = await storage . upload ( buffer , image . name );
return { url , caption };
}
})
};
Use in a form:
< form method = "POST" action = { actions . uploadImage } enctype = "multipart/form-data" >
< input type = "file" name = "image" accept = "image/*" required />
< input type = "text" name = "caption" placeholder = "Caption" />
< button type = "submit" > Upload </ button >
</ form >
Client-Side Actions
Call actions from client-side JavaScript:
src/pages/interactive.astro
---
import { actions } from 'astro:actions' ;
---
< form id = "subscribe-form" >
< input type = "email" name = "email" required />
< button type = "submit" > Subscribe </ button >
</ form >
< div id = "result" ></ div >
< script >
import { actions } from 'astro:actions' ;
const form = document . getElementById ( 'subscribe-form' );
const result = document . getElementById ( 'result' );
form . addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ();
const formData = new FormData ( form );
const { data , error } = await actions . subscribe ( formData );
if ( error ) {
result . textContent = `Error: ${ error . message } ` ;
} else {
result . textContent = data . message ;
form . reset ();
}
});
</ script >
Client-side actions require JavaScript. Always provide a fallback form that works without JS.
Error Handling
Throw ActionError for expected errors:
import { defineAction , ActionError } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
login: defineAction ({
input: z . object ({
email: z . string (). email (),
password: z . string (). min ( 8 )
}),
handler : async ({ email , password }, context ) => {
const user = await db . users . findByEmail ( email );
if ( ! user || ! await verifyPassword ( password , user . passwordHash )) {
throw new ActionError ({
code: 'UNAUTHORIZED' ,
message: 'Invalid email or password'
});
}
// Set session cookie
context . cookies . set ( 'session' , user . sessionId , {
httpOnly: true ,
secure: true ,
maxAge: 60 * 60 * 24 * 7 // 1 week
});
return { user };
}
})
};
Error Codes
Context Object
Access request context in action handlers:
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
updateProfile: defineAction ({
input: z . object ({
name: z . string ()
}),
handler : async ({ name }, context ) => {
// Access cookies
const userId = context . cookies . get ( 'userId' )?. value ;
// Access request data
console . log ( 'User agent:' , context . request . headers . get ( 'user-agent' ));
console . log ( 'Client IP:' , context . clientAddress );
// Access shared locals from middleware
const user = context . locals . user ;
if ( ! user ) {
throw new ActionError ({
code: 'UNAUTHORIZED' ,
message: 'Please log in'
});
}
await db . users . update ( user . id , { name });
return { success: true };
}
})
};
JSON Actions
By default, actions accept JSON input:
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
createComment: defineAction ({
// JSON is the default accept type
input: z . object ({
postId: z . string (),
content: z . string (). min ( 1 ). max ( 1000 )
}),
handler : async ({ postId , content }, context ) => {
const user = context . locals . user ;
if ( ! user ) {
throw new ActionError ({
code: 'UNAUTHORIZED' ,
message: 'Must be logged in to comment'
});
}
const comment = await db . comments . create ({
postId ,
userId: user . id ,
content
});
return comment ;
}
})
};
Call from client code:
import { actions } from 'astro:actions' ;
const { data , error } = await actions . createComment ({
postId: '123' ,
content: 'Great post!'
});
if ( error ) {
console . error ( error . message );
} else {
console . log ( 'Comment created:' , data );
}
Progressive Enhancement
Actions work without JavaScript but enhance when available:
---
import { actions } from 'astro:actions' ;
const result = Astro . getActionResult ( actions . contact );
---
< html >
< body >
< form method = "POST" action = { actions . contact } id = "contact-form" >
< input name = "name" required />
< input type = "email" name = "email" required />
< textarea name = "message" required ></ textarea >
< button type = "submit" > Send </ button >
</ form >
{ result ?. success && < p > Message sent! </ p > }
{ result ?. error && < p > Error: { result . error . message } </ p > }
< script >
import { actions } from 'astro:actions' ;
const form = document . getElementById ( 'contact-form' );
form ?. addEventListener ( 'submit' , async ( e ) => {
e . preventDefault ();
const formData = new FormData ( form );
const { data , error } = await actions . contact ( formData );
if ( error ) {
alert ( `Error: ${ error . message } ` );
} else {
alert ( 'Message sent successfully!' );
form . reset ();
}
});
</ script >
</ body >
</ html >
Form submits via POST request
Page reloads with action result
Result available via getActionResult()
Form prevents default submission
Action called via client-side JavaScript
No page reload required
Smooth user experience
Type Safety
Actions are fully typed:
import { actions } from 'astro:actions' ;
// Input is typed from your schema
const result = await actions . createPost ({
title: 'My Post' ,
content: 'Content here' ,
published: true
});
// Result is typed
if ( result . data ) {
console . log ( result . data . id ); // string
}
if ( result . error ) {
console . log ( result . error . message ); // string
console . log ( result . error . code ); // ErrorCode
console . log ( result . error . fields ); // Field errors
}
Organizing Actions
For larger projects, organize actions into separate files:
import { posts } from './posts' ;
import { users } from './users' ;
import { comments } from './comments' ;
export const server = {
... posts ,
... users ,
... comments
};
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const posts = {
createPost: defineAction ({
input: z . object ({
title: z . string (),
content: z . string ()
}),
handler : async ({ title , content }) => {
// Implementation
}
}),
deletePost: defineAction ({
input: z . object ({
id: z . string ()
}),
handler : async ({ id }) => {
// Implementation
}
})
};
Advanced Patterns
Optimistic Updates
Batch Operations
File Validation
< script >
import { actions } from 'astro:actions' ;
async function likePost ( postId : string ) {
// Optimistically update UI
const button = document . querySelector ( `[data-post=" ${ postId } "]` );
button ?. classList . add ( 'liked' );
// Call action
const { error } = await actions . likePost ({ postId });
// Revert on error
if ( error ) {
button ?. classList . remove ( 'liked' );
alert ( 'Failed to like post' );
}
}
</ script >
import { defineAction } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
batchDelete: defineAction ({
input: z . object ({
ids: z . array ( z . string ())
}),
handler : async ({ ids }) => {
await db . posts . deleteMany ( ids );
return { deleted: ids . length };
}
})
};
import { defineAction , ActionError } from 'astro:actions' ;
import { z } from 'astro:schema' ;
export const server = {
uploadAvatar: defineAction ({
accept: 'form' ,
input: z . object ({
avatar: z . instanceof ( File )
}),
handler : async ({ avatar }) => {
// Validate file type
if ( ! avatar . type . startsWith ( 'image/' )) {
throw new ActionError ({
code: 'BAD_REQUEST' ,
message: 'File must be an image'
});
}
// Validate file size (5MB)
if ( avatar . size > 5 * 1024 * 1024 ) {
throw new ActionError ({
code: 'BAD_REQUEST' ,
message: 'File must be under 5MB'
});
}
// Process upload
const url = await uploadFile ( avatar );
return { url };
}
})
};
Middleware Add authentication and shared logic
API Routes Build custom API endpoints
Type Safety TypeScript configuration