We Quietly Shipped Two Things
Yesterday we open-sourced Luat. But we didn't mention everything that came with it.
Quietly bundled in that release were two features we've been using internally for a while: Form Actions and a KV Store. One handles mutations, the other handles persistence. Together they solve most of what you need for building real applications.
Part 1: Form Actions
The Gap We Needed to Fill
The load() function in +page.server.lua handles GET requests beautifully. You fetch data, return it, and your template renders with fresh props. Simple.
But what about POST requests? Form submissions? Updates? Deletions?
We'd been using +server.lua API routes for everything, which worked fine but felt clunky for page-level mutations. You'd have a page that displays a form, but the form submission would hit a completely separate API endpoint. State management got messy.
We wanted something better. Inspired by SvelteKit's approach, we built form actions for Luat.
Enter Form Actions
Form actions let you handle mutations in the same file as your data loading:
-- src/routes/blog/[slug]/edit/+page.server.lua
function load(ctx)
return {
post = fetch_post(ctx.params.slug)
}
end
actions = {
default = function(ctx)
update_post(ctx.params.slug, ctx.form)
return { saved = true }
end,
publish = function(ctx)
publish_post(ctx.params.slug)
return {
headers = { ["HX-Redirect"] = "/blog/" .. ctx.params.slug }
}
end,
delete = function(ctx)
delete_post(ctx.params.slug)
return { redirect = "/blog" }
end
}
GET requests run load(). POST/PUT/DELETE requests run the appropriate action.
Named Actions via URL
Actions are targeted using a ?/name suffix:
POST /blog/my-post/edit → actions.default
POST /blog/my-post/edit?/publish → actions.publish
POST /blog/my-post/edit?/delete → actions.delete
This makes it trivial to have multiple forms on a page, each targeting a different action:
<form method="POST">
<input name="title" value="{props.post.title}" />
<button>Save Draft</button>
</form>
<form method="POST" action="?/publish">
<button>Publish</button>
</form>
<form method="POST" action="?/delete">
<button>Delete</button>
</form>
The fail() Helper
Validation errors are a fact of life. The fail() helper makes them easy to handle:
actions = {
default = function(ctx)
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
save_post(ctx.form)
return { saved = true }
end
}
The returned data includes the original form values, so users don't lose their input.
Action Templates (Fragments)
When an action returns data, Luat looks for a matching template in the (fragments) subfolder:
src/routes/login/
├── +page.luat # The login form
├── +page.server.lua # load() + actions
└── (fragments)/
└── default.luat # Renders after form submission
Templates can also be method-specific. For example, POST-delete.luat takes precedence over delete.luat for POST requests.
This is perfect for HTMX partial updates. Your form submits, the action runs, and a small HTML fragment comes back to update just the relevant part of the page.
Part 2: KV Store
The Other Gap
Form actions solved mutations. But where does the data go?
During development, we kept reaching for external databases or writing JSON files to disk. It worked, but it felt heavy for simple use cases. We wanted something built-in, something that just works.
So we built a KV store directly into Luat.
Simple API
local kv = KV.namespace("blog")
-- Store data
kv:put("post:hello-world", json.encode({
title = "Hello World",
content = "My first post"
}))
-- Retrieve data
local post = kv:get("post:hello-world", "json")
print(post.title) -- "Hello World"
-- Delete data
kv:delete("post:hello-world")
-- List keys
local result = kv:list({ prefix = "post:" })
for _, key in ipairs(result.keys) do
print(key.name)
end
Expiration Support
Cache entries that auto-expire:
kv:put("session:abc123", session_data, {
expirationTtl = 3600 -- expires in 1 hour
})
kv:put("token:xyz", secret, {
expiration = os.time() + 86400 -- expires tomorrow
})
Metadata
Store additional info alongside your data:
kv:put("doc:readme", content, {
metadata = {
author = "alice",
version = 2,
updated = os.time()
}
})
local value, meta = kv:getWithMetadata("doc:readme")
print(meta.metadata.author) -- "alice"
Platform Support
- CLI: SQLite-backed storage. Data persists in
.luat/data/kv.db. - WASM: In-memory storage. Data persists for the browser session.
The API is identical on both platforms. Write once, run anywhere.
Putting It Together
Here's a complete blog module using both features:
-- src/lib/blog.lua
local kv = KV.namespace("blog")
local M = {}
function M.get_posts()
local result = kv:list({ prefix = "post:" })
local posts = {}
for _, key in ipairs(result.keys) do
local post = kv:get(key.name, "json")
if post then table.insert(posts, post) end
end
return posts
end
function M.get_post(slug)
return kv:get("post:" .. slug, "json")
end
function M.save_post(slug, data)
data.updated = os.time()
kv:put("post:" .. slug, json.encode(data))
return data
end
function M.delete_post(slug)
kv:delete("post:" .. slug)
end
return M
-- src/routes/blog/[slug]/edit/+page.server.lua
local blog = require("blog")
function load(ctx)
local post = blog.get_post(ctx.params.slug)
if not post then
return { error = "Not found", status = 404 }
end
return { post = post }
end
actions = {
default = function(ctx)
if not ctx.form.title or ctx.form.title == "" then
return fail(400, { error = "Title required" })
end
blog.save_post(ctx.params.slug, ctx.form)
return { saved = true }
end,
delete = function(ctx)
blog.delete_post(ctx.params.slug)
return { redirect = "/blog" }
end
}
No external database. No API routes for simple CRUD. Just forms and a KV store.
Why We Like This
-
Colocated code - Load functions and actions live together. Related logic stays together.
-
Built-in persistence - No database setup for simple apps. The KV store is always there.
-
Progressive enhancement - Forms work without JavaScript. Add HTMX for partial updates if you want.
-
Clean URLs - The
?/actionNameformat is unobtrusive. -
Platform portable - Same code runs on CLI and WASM.
Available Now
Both features are included in the latest release. Check the documentation:
We've been using these patterns for months internally. They're solid, they're useful, and now they're yours too.
Questions? Found a bug? Open an issue on GitHub.
