Server Actions
Server actions let your React components call Python functions on the server. They are the mutation counterpart to @server loaders -- loaders read data, actions write data.
Defining an action
Use the @action decorator on an async function in your .pyxl file:
@action
async def create_post(request):
body = await request.json()
title = body["title"]
content = body["content"]
# ... save to database ...
return {"id": 1, "title": title}The function:
- Must be
async - Receives a Starlette
Requestobject - Must return a JSON-serializable
dict - Can only be called via
POSTrequest
Multiple actions can exist in the same .pyxl file alongside a @server loader.
Calling actions from React
Using the <Form> component
The simplest way to call an action from a form. <Form> collects the inputs, posts them to the action, and exposes onSuccess / onError callbacks:
@action
async def create_post(request):
body = await request.json()
return {"id": 1, "title": body["title"]}import { Form } from 'pyxle/client';
export default function NewPostPage() {
return (
<Form
action="create_post"
onSuccess={(data) => console.log('Created:', data.id)}
onError={(msg) => console.error('Failed:', msg)}
>
<input name="title" placeholder="Post title" required />
<textarea name="content" placeholder="Write something..." />
<button type="submit">Create Post</button>
</Form>
);
}<Form> props:
| Prop | Type | Description |
|---|---|---|
action |
string |
Name of the @action function |
pagePath |
string? |
Override which page the action belongs to (defaults to current page) |
onSuccess |
(data) => void |
Called with response data on success |
onError |
(message, fields) => void |
Called with the error message and, for a 422 validation failure, the per-field errors ({ [field]: string[] }) — otherwise null |
resetOnSuccess |
boolean |
Reset form fields after success (default: true) |
Using the useAction hook
For programmatic calls (not form submissions), use the useAction hook:
import { useAction } from 'pyxle/client';
export default function ProfilePage({ data }) {
const updateName = useAction('update_name');
async function handleClick() {
const result = await updateName({ name: 'Alice' });
if (result.ok) {
console.log('Updated!');
}
}
return (
<div>
<p>Name: {data.user.name}</p>
<button onClick={handleClick} disabled={updateName.pending}>
{updateName.pending ? 'Saving...' : 'Change to Alice'}
</button>
{updateName.error && <p style={{ color: 'red' }}>{updateName.error}</p>}
</div>
);
}useAction returns an async function with attached state:
| Property | Type | Description |
|---|---|---|
pending |
boolean |
true while the request is in flight |
error |
string | null |
Error message on failure, null otherwise |
fields |
Record<string, string[]> | null |
Per-field validation errors from the last failed submit (a 422), or null. Cleared when a new request starts |
data |
object | null |
Last successful response data |
The value you await is the flat result — { ok: true, ...yourReturn } on success — so read
returned fields directly off it (result.title), not under result.data. A successful result
has no .data. The data property in the table above is the hook's convenience copy of that same
payload, for reading it outside the call site — don't confuse the two.
Options:
| Option | Type | Description |
|---|---|---|
pagePath |
string? |
Override which page the action belongs to |
onMutate |
(payload) => void |
Called immediately before the request (for optimistic updates) |
Error handling in actions
Raise ActionError to return a structured error response:
@action
async def delete_post(request):
body = await request.json()
post = await fetch_post(body["id"])
if post is None:
raise ActionError("Post not found", status_code=404)
if post["author_id"] != request.state.user_id:
raise ActionError("Not authorised", status_code=403)
await db.delete(post)
return {"deleted": True}Since 0.3.0,
ActionErroris auto-imported by the compiler for any.pyxlfile that declares at least one@action. No explicit import is required. A user-definedActionErrorclass (or an existing import) is respected and takes precedence.
The client receives:
{ "ok": false, "error": "Not authorised" }Validating request bodies with Pydantic
Reading await request.json() and pulling fields out by hand means writing the
same "is this present, is it the right type, is it in range" checks in every
action. Pyxle can do that for you: annotate a parameter with a
Pydantic model and the dispatcher parses, coerces,
and validates the request body before your action runs. Your function only
executes with a valid, fully-typed model.
from pydantic import BaseModel, EmailStr, Field
class CreatePost(BaseModel):
title: str = Field(min_length=1, max_length=120)
content: str
tags: list[str] = []
@action
async def create_post(request, body: CreatePost):
# `body` is a validated CreatePost instance — no manual checks needed.
post = await db.insert_post(title=body.title, content=body.content)
return {"id": post.id, "title": post.title}The body parameter:
- Is the first parameter (after
request) annotated with a PydanticBaseModelsubclass. The name is up to you (body,payload,form, …). - May be
Optional[Model]/Model | Nonefor an optional body. - Is parsed from the JSON request body.
<Form>submissions (which post form fields) validate the same way.
Pydantic is an optional dependency. Install it with the extra:
pip install "pyxle-framework[pydantic]"If an action declares a required Pydantic body but Pydantic isn't installed, the request
fails with a clear 500 (and the dev server logs why) — actions without a model
parameter are unaffected. An optional body (Model | None with a default) silently
skips validation when Pydantic is absent: the action runs with the body never validated
and the parameter left at its default.
What the client receives on a validation failure
When the body fails validation, the action is not called. The client gets a
422 response with a top-level fields map — field path to a list of messages:
{
"ok": false,
"error": "Validation failed",
"fields": {
"title": ["String should have at least 1 character"],
"tags.0": ["Input should be a valid string"]
}
}Field paths are dotted for nested models (address.zip) and indexed for list
items (tags.0). Whole-body errors — a malformed JSON body, or a root-level
model error — use the key __root__ rather than a field name; render it as a
form-level message.
Showing field errors in the UI
Both useAction and <Form> surface the fields map so you can render messages
next to each input. With useAction, read result.fields (or the reactive
submit.fields):
import { useAction } from 'pyxle/client';
export default function NewPostPage() {
const submit = useAction('create_post');
async function onSubmit(event) {
event.preventDefault();
const form = new FormData(event.target);
const result = await submit(Object.fromEntries(form));
if (result.ok) {
// navigate, toast, etc.
}
}
return (
<form onSubmit={onSubmit}>
<input name="title" />
{submit.fields?.title && <p className="error">{submit.fields.title[0]}</p>}
<textarea name="content" />
<button disabled={submit.pending}>Create</button>
</form>
);
}With <Form>, the field map is the second argument to onError:
<Form
action="create_post"
onError={(message, fields) => setFieldErrors(fields ?? {})}
>
{/* inputs */}
</Form>Raising validation errors yourself
For checks Pydantic can't express — uniqueness, cross-field rules, anything that
needs a database — raise ValidationActionError. It produces the same 422
shape, so the client handles model-level and hand-rolled errors identically:
from pyxle.runtime import ValidationActionError
@action
async def register(request, body: Signup):
if await db.email_taken(body.email):
raise ValidationActionError(fields={"email": ["That email is already registered."]})
...ValidationActionError is auto-imported alongside ActionError in any .pyxl
file that declares an @action. See the Runtime API reference.
Exporting an OpenAPI schema
Because the body models are real Pydantic models, Pyxle can generate an OpenAPI 3.1 document describing every action's request body straight from your code:
pyxle openapi --out openapi.jsonSee the CLI reference for options.
Invalidating client caches after a mutation
Pyxle's client router caches loader payloads (for 2 minutes by default, or the route's configured cache TTL) so back/forward navigation is instant. A mutation that changes data the user might navigate back to should invalidate the stale route so the next visit refetches:
from pyxle.runtime import invalidate_routes
@action
async def delete_post(request):
body = await request.json()
await db.delete_post(body["id"])
# Drop the client's cached /posts list so the next visit refetches.
return invalidate_routes({"ok": True}, "/posts", "/dashboard")Pyxle attaches an x-pyxle-invalidate: /posts, /dashboard header to the response. <Form> and useAction honour the header automatically — neither the calling component nor the page component needs to know about it.
For purely client-driven invalidation (no server roundtrip), use invalidate(url) from pyxle/client.
Running work after the response
To do work without making the client wait — send an email, emit a webhook —
schedule it on request.state.background. Pyxle runs it after the response is
sent:
@action
async def signup(request, body: Signup):
user = await db.create_user(body.email)
request.state.background.add_task(send_welcome_email, user.email)
return {"id": user.id}For a single task you can return the shorthand {"background": [fn, *args]}
instead. For fire-and-forget work that isn't tied to this response (including
from @server loaders), use pyxle.tasks.enqueue. See the Background Tasks guide.
How actions are routed
Each @action function gets an automatic endpoint:
POST /api/__actions/{page_path}/{action_name}For example, create_post in pages/blog/new.pyxl is available at:
POST /api/__actions/blog/new/create_postYou do not need to know these URLs -- <Form> and useAction resolve them automatically.
Calling an action directly (scripts and tests)
App code never needs the URL, but a script or test can POST to it directly. Send a JSON body; the response is the action's return dict (with ok: true), or { "ok": false, "error": "..." } when it raises an ActionError:
curl -X POST http://localhost:8000/api/__actions/blog/new/create_post \
-H "Content-Type: application/json" \
-d '{"title": "Hello"}'CSRF protection is on by default, so a raw curl like this is rejected with 403 unless you either exempt the action path in pyxle.config.json ("csrf": { "exemptPaths": ["/api/__actions/"] }) or send a valid X-CSRF-Token header matching the CSRF cookie (pyxle-csrf-<port> by default, e.g. pyxle-csrf-8000 — see Security).
CSRF protection
Actions are protected by CSRF middleware by default. The <Form> component and useAction hook handle token management automatically. See Security for details.
Route policies on actions
Actions run through the same route-hook pipeline as pages and API routes, so an auth or rate-limit policy can apply to @action POSTs. Register action hooks under routeMiddleware.actions — an action hook should reject with a JSON 401/403 (not an HTML redirect, which a page hook might use). See the Middleware guide.
Next steps
- Wrap pages in layouts: Layouts
- Handle errors with boundaries: Error Handling