Actions
Actions handle form submissions. They are plain HTML forms posting to server functions in +page.server.ts so they work without JavaScript, and the framework intercepts the submit to fetch and re-render when it is available.
// routes/posts/new/+page.server.ts
import { ok, seeOther } from "@torpor/build";
import { type PageServerEndPoint } from "@torpor/build";
import { createPost } from "@/lib/db";
export default {
actions: {
default: async ({ form }) => {
const { title } = await form();
await createPost({ title });
return seeOther("/posts");
},
},
} satisfies PageServerEndPoint; // views/NewPost.torp
export default function NewPost() {
@render {
<form method="POST">
<input name="title" />
<button>Create</button>
</form>
}
} Named actions
A form without an action attribute calls the default action. Point the action at a query value to call a named one:
<form method="POST" action="?/like">
<button>Like</button>
</form> actions: {
like: async ({ params }) => {
await addLike(params.id);
},
}, Validation
Declare a schema for an action (any Standard Schema library like Zod, Valibot, or ArkType works) and its form values are validated and typed before the action runs. A form that fails validation gets a 422 response with the schema's issues, without calling your action:
const schema = {
default: z.object({ title: z.string().min(1) }),
};
export default {
schema,
actions: {
default: async ({ form }) => {
const { title } = await form(); // typed as string
await createPost({ title });
return ok({ saved: true });
},
},
} satisfies PageServerEndPoint<"/posts/new", Record<string, any>, typeof schema>; Action results
When an action returns a response with a JSON body — from ok({ ... }), unprocessable({ ... }), etc — the page is re-rendered and the body becomes $props.form (also available as $page.form from @torpor/build/state):
export default function NewPost($props: { form?: { saved?: boolean } }) {
@render {
@if ($props.form?.saved) {
<p>Saved!</p>
}
<form method="POST">
<input name="title" />
<button>Create</button>
</form>
}
} Use unprocessable to return validation errors with their field types, and seeOther to redirect after a successful submission.
Flash messages
An action can set a one-time message, typically before a redirect, which the new page shows. It rides in a cookie that is deleted when read, so it shows exactly once:
import { ok, seeOther } from "@torpor/build";
actions: {
default: async (event) => {
await createPost(await event.form());
event.flash.set("Post created"); // { message: "Post created" }
return seeOther("/posts");
},
}, Since redirects only ask the browser for the next page, the values are read by the framework into $page.flash, an object which stays current until the next page load (see $page). Custom values are allowed too, for example { error: "Import failed", retryable: true } — they must be JSON-safe and small enough to fit in the cookie:
// Anywhere on the redirected page
@if ($page.flash?.message) {
<p class="message">{$page.flash.message}</p>
} Flash messages require cookies. Without them (private browsing, blocked cookies), the action still completes — only the message is skipped.