Skip to main content

Form Actions

Form actions handle form submissions and mutations in Luat applications. Inspired by SvelteKit, they provide a clean way to process POST, PUT, and DELETE requests on page routes.

Overview​

While load() functions handle data fetching for GET requests, form actions handle mutations. They're defined in +page.server.lua alongside your load function.

-- src/routes/blog/[slug]/edit/+page.server.lua

-- Data loading (GET requests)
function load(ctx)
return {
post = fetch_post(ctx.params.slug)
}
end

-- Form actions (POST/PUT/DELETE requests)
actions = {
default = function(ctx)
update_post(ctx.params.slug, ctx.form)
return { success = true }
end
}

URL Format​

Form actions use a special URL format with ?/actionName:

URLAction
POST /blog/editactions.default
POST /blog/edit?/publishactions.publish
POST /blog/edit?/loginactions.login
PUT /blog/edit?/updateactions.update (or actions.update.put)

The ?/ prefix distinguishes action names from query parameters.

Defining Actions​

Basic Actions​

Define actions as functions in the actions table:

-- src/routes/login/+page.server.lua

actions = {
-- Default action: handles POST without ?/name
default = function(ctx)
local email = ctx.form.email
local password = ctx.form.password

if not email or email == "" then
return fail(400, { error = "Email is required" })
end

local user = authenticate(email, password)
if not user then
return fail(401, {
email = email,
error = "Invalid credentials"
})
end

return {
success = true,
redirect = "/dashboard"
}
end,

-- Named action: handles POST ?/register
register = function(ctx)
local email = ctx.form.email
local name = ctx.form.name

local user = create_user(email, name)
return { success = true, user = user }
end
}

Method-Specific Actions​

For REST-style APIs, you can define method-specific handlers:

actions = {
-- Function handles any method
publish = function(ctx)
publish_post(ctx.params.slug)
return { published = true }
end,

-- Table handles specific methods
update = {
post = function(ctx)
-- Handle POST ?/update
return update_post(ctx.params.slug, ctx.form)
end,
put = function(ctx)
-- Handle PUT ?/update
return replace_post(ctx.params.slug, ctx.form)
end,
delete = function(ctx)
-- Handle DELETE ?/update
return delete_post(ctx.params.slug)
end
}
}

Context Object​

Action handlers receive a context object with request information:

function(ctx)
-- Form data (parsed from request body)
local title = ctx.form.title
local content = ctx.form.content

-- Route parameters
local slug = ctx.params.slug

-- Query parameters (excluding ?/action)
local page = ctx.query.page

-- Request headers
local auth = ctx.headers["Authorization"]

-- Request metadata
local method = ctx.method -- "POST", "PUT", etc.
local url = ctx.url -- Full request URL

-- Aliases for form data
local body = ctx.body -- Same as ctx.form
local json = ctx.json -- Same as ctx.form
end

Response Format​

Actions return a table that becomes the response:

return {
-- HTTP status code (default: 200)
status = 201,

-- Response headers
headers = {
["X-Custom"] = "value",
["HX-Redirect"] = "/success"
},

-- Response data (returned as JSON or passed to template)
success = true,
message = "Created successfully"
}

Shorthand: Redirect​

Use the redirect key for server-side redirects:

return {
redirect = "/dashboard"
}
-- Equivalent to: status = 302, headers = { Location = "/dashboard" }

The fail() Helper​

The fail() function creates error responses:

-- Basic fail
return fail(400, { error = "Validation failed" })

-- Fail with form data (for re-populating fields)
return fail(400, {
email = ctx.form.email,
error = "Password is too short"
})

-- Fail with custom headers
return fail(400, {
error = "Invalid input",
headers = {
["HX-Retarget"] = "#error-container"
}
})

fail(status, data) is equivalent to:

return {
status = status,
-- ... all keys from data
}

Fragments (HTML Responses)​

When an action returns data, Luat can render it as HTML using a fragment template. Fragments are partial HTML responses (without the full page wrapper) - perfect for HTMX partial updates.

Place fragment templates in a (fragments) subfolder:

src/routes/login/
├── +page.luat
├── +page.server.lua
└── (fragments)/
└── default.luat # Renders after form submission

The action's return value becomes props in the fragment:

actions = {
default = function(ctx)
return { success = true, message = "Logged in!" }
end
}
<!-- (fragments)/default.luat -->
<div class="alert">{props.message}</div>

If no matching fragment exists, the response is returned as JSON.

For detailed coverage of fragments including method-specific templates, GET actions, and naming conventions, see Fragments in Advanced Routing.

HTMX Integration​

Form actions work seamlessly with HTMX. Fragments return HTML that HTMX can swap into the page without a full reload.

Basic Example​

<!-- +page.luat -->
<form method="POST" action="?/add" hx-post="?/add" hx-target="#result">
<input name="title" placeholder="New item" />
<button type="submit">Add</button>
</form>

<div id="result"></div>
-- +page.server.lua
actions = {
add = function(ctx)
local item = create_item(ctx.form.title)
return { item = item }
end
}
<!-- (fragments)/add.luat -->
<p>Added: {props.item.title}</p>

When the form submits, HTMX posts to ?/add, the action runs, and add.luat renders HTML that gets swapped into #result.

Response Headers​

You can control HTMX behavior with response headers:

return {
success = true,
headers = {
["HX-Redirect"] = "/dashboard", -- Client-side redirect
["HX-Trigger"] = "itemAdded", -- Trigger events
["HX-Reswap"] = "innerHTML", -- Change swap method
["HX-Refresh"] = "true" -- Refresh page
}
}

For more patterns, see HTMX Patterns and HTMX & Fragments Guide.

Complete Example​

Here's a full example of a blog post editor with multiple actions:

File Structure​

src/routes/blog/[slug]/edit/
├── +page.luat
├── +page.server.lua
└── (fragments)/
├── default.luat
└── publish.luat

Server File​

-- +page.server.lua
local blog = require("blog")

function load(ctx)
local post = blog.get_post(ctx.params.slug)
if not post then
return { error = "Post not found", status = 404 }
end
return { post = post }
end

actions = {
-- Save draft (default action)
default = function(ctx)
local slug = ctx.params.slug

if not ctx.form.title or ctx.form.title == "" then
return fail(400, {
title = ctx.form.title,
content = ctx.form.content,
error = "Title is required"
})
end

blog.update_post(slug, {
title = ctx.form.title,
content = ctx.form.content
})

return { saved = true }
end,

-- Publish post
publish = function(ctx)
local slug = ctx.params.slug
blog.publish_post(slug)

return {
published = true,
headers = {
["HX-Redirect"] = "/blog/" .. slug
}
}
end,

-- Delete post
delete = function(ctx)
local slug = ctx.params.slug
blog.delete_post(slug)

return {
deleted = true,
redirect = "/blog"
}
end
}

Page Template​

<!-- +page.luat -->
<script>
local post = props.post
</script>

<h1>Edit: {post.title}</h1>

<form method="POST" hx-post hx-target="#save-result">
<input name="title" value="{post.title}" placeholder="Title" />
<textarea name="content">{post.content}</textarea>
<button type="submit">Save Draft</button>
</form>

<div id="save-result"></div>

<form method="POST" action="?/publish" hx-post="?/publish">
<button type="submit">Publish</button>
</form>

<form method="POST" action="?/delete">
<button type="submit" onclick="return confirm('Delete this post?')">
Delete
</button>
</form>

Action Template​

<!-- (fragments)/default.luat (save result) -->
{#if props.error}
<div class="bg-red-100 text-red-700 p-4 rounded">
{props.error}
</div>
{:else if props.saved}
<div class="bg-green-100 text-green-700 p-4 rounded">
Draft saved!
</div>
{/if}