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.