Luat Projects
Luat is a template language with Svelte-like syntax whose server code is written in Lua. On Maravilla, Luat runs next to the JavaScript frameworks with the same platform: KV, database, storage, auth, realtime, queues, event handlers, crons, durable workflows and MCP tools.
A Luat project needs no package.json, no bundler and no Node install. Pages render on the server; add interactivity with HTMX or Alpine.js.
This page covers what Maravilla adds: the project setup, platform services, events, workflows, MCP tools and deployment. For the template language itself — syntax, components, layouts, routing, form actions and fragments — see the Luat documentation. Building a Studio website in Luat? See Luat sites in the Studio docs.
Start a project
From the console: create a project and pick Luat starter, or open an empty project’s Code page and click Start a Luat project. The starter is committed to the repository and deployed on the first pipeline run.
From the CLI:
maravilla init my-site # choose "Luat" in the framework list
cd my-site
maravilla dev # http://localhost:3001
Project layout
luat.toml project settings
src/
app.html page shell (%luat.head%, %luat.body%, %luat.title%)
routes/
+layout.luat layout around every page
+page.luat the home page
+page.server.lua its load function and form actions
+error.luat error page
api/hello/+server.lua a JSON API route
lib/ shared modules — require("$lib/format")
public/ static files, served at /public/…
events/ event handlers and crons (optional)
workflows/ durable workflows (optional)
mcp/ MCP tools (optional)
.maravilla/pipeline/build.yml
Pages, data and forms
A page’s +page.server.lua loads its data; the template receives it as props:
-- src/routes/guestbook/+page.server.lua
local platform = require("maravilla")
function load(ctx)
return { entries = platform.DB.find("guestbook", {}, { sort = { created_at = -1 }, limit = 20 }) }
end
actions = {
default = function(ctx)
if (ctx.form.message or "") == "" then
return fail(400, { error = "Say something!" })
end
platform.DB.insertOne("guestbook", { message = ctx.form.message, created_at = os.time() })
return { ok = true }
end,
}
<!-- src/routes/guestbook/+page.luat -->
<ul>
{#each props.entries as entry}
<li>{entry.message}</li>
{:empty}
<li>No entries yet.</li>
{/each}
</ul>
ctx carries the request: ctx.params, ctx.query, ctx.form, ctx.cookies, ctx.headers, plus ctx.setCookie, ctx.deleteCookie and ctx.error(status, message). To redirect, return { redirect = "/login" } from load; redirect and status are reserved keys of the returned table, not props. An HTMX request to a form action is answered with the matching fragment from (fragments)/<action>.luat, so partial updates need no extra routes.
API routes export one function per method from +server.lua:
function GET(ctx)
return { body = { message = "Hello", time = os.time() } }
end
Platform services
require("maravilla") returns the same platform the JavaScript frameworks get, with the same method names. Calls look synchronous — no callbacks or await:
local platform = require("maravilla")
platform.KV.sessions.put("abc", { user = "u1" }, { expirationTtl = 3600 })
local session = platform.KV.sessions.get("abc")
local posts = platform.DB.find("posts", { published = true }, { limit = 10 })
platform.DB.updateOne("posts", { _id = id }, { ["$inc"] = { views = 1 } })
local bytes = platform.STORAGE.get("uploads/logo.png")
platform.queue.send("emails", { to = "ada@example.com" })
platform.realtime.publish("chat", { text = "hi" })
platform.media.transforms.resize("uploads/photo.jpg", { width = 1600, format = "webp" })
platform.push.send({ userId = user.id }, { title = "Your order shipped" })
local user = platform.auth.getCurrentUser()
if platform.auth.can("edit", "posts", post) then ... end
local secret = platform.env.API_KEY
Access policies apply exactly as they do for JavaScript projects. Errors (for example a denied write) are raised as Lua errors; catch them with pcall when you want to handle them.
Event handlers and crons
Files under events/ return a table of handlers:
-- events/jobs.lua
local platform = require("maravilla")
local events = platform.events
return {
nightly = events.onSchedule("0 3 * * *", function(event, ctx)
platform.DB.deleteMany("sessions", { expired = true })
end),
welcome = events.onRegister(function(event, ctx)
platform.queue.send("emails", { to = event.data.email, template = "welcome" })
end),
emails = events.onQueue("emails", { batch = 10 }, function(messages, ctx)
for _, message in ipairs(messages) do
-- send it
end
end),
}
Available triggers: onKvChange, onDbChange, onAuth, onRegister, onSchedule, onQueue, onChannel, onDeploy, onStorage and defineEvent. They behave like their JavaScript counterparts. Event files should only define handlers when they load; do the work inside the handler.
Durable workflows
-- workflows/onboarding.lua
local platform = require("maravilla")
return {
onboarding = platform.workflows.defineWorkflow({ id = "onboarding" }, function(input, step, ctx)
local user = step.run("load-user", function()
return platform.DB.findOne("users", { _id = input.userId })
end)
step.sleep("wait-a-day", "1d")
local reply = step.waitForEvent("reply", { type = "user.replied", timeout = "7d" })
step.run("follow-up", function()
platform.queue.send("emails", { to = user.email, replied = reply ~= nil })
end)
end),
}
Start a run with platform.workflows.start("onboarding", { userId = id }). Steps are recorded: when a run resumes after a sleep or an event, completed steps return their saved result instead of running again — so give every step a unique name and keep side effects inside steps. See Workflows for the full model.
MCP tools
-- mcp/notes.lua
local platform = require("maravilla")
local mcp = platform.mcp
return {
server = mcp.defineMcpServer({ name = "notes", version = "1.0.0" }),
search = mcp.defineMcpTool({
name = "search_notes",
description = "Search the user's notes",
inputSchema = { type = "object", properties = { q = { type = "string" } }, required = { "q" } },
scopes = { "notes:read" },
}, function(args, ctx)
local notes = platform.DB.find("notes", { owner = ctx.user.id, text = { ["$regex"] = args.q } })
return { content = { { type = "json", json = notes } } }
end),
}
ctx.user and ctx.client describe the caller, and policies see the MCP user, as in MCP Servers. UI widgets are not available for Luat projects yet.
Develop, build, deploy
| Command | What it does |
|---|---|
maravilla dev | Builds the project, serves it with local platform services and rebuilds on every change. Errors are shown in the browser. |
maravilla build | Compiles templates and Lua into build/ (lists the event handlers, workflows and MCP tools it found). |
maravilla preview | Serves build/ exactly as production does. |
maravilla check | Verifies build/ is deployable. |
Pushing to main deploys through .maravilla/pipeline/build.yml:
name: build-and-deploy
image: node:22
on:
push:
branches: [main]
webhook:
deploy: true
jobs:
build:
commands:
- npm install -g @maravilla-labs/cli@latest
- maravilla build
- maravilla check
artifacts:
paths: [build/]
webhook: deploy: true is what turns a green pipeline into a deployment — keep it when you edit the file. See Pipelines.
Limits
Every request, event handler, workflow step and MCP call runs in its own isolated environment with your plan’s memory and time limits, so a slow page cannot affect other requests. Lua’s io, os.execute, file access and debug are not available; everything outside the request goes through require("maravilla").