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

CommandWhat it does
maravilla devBuilds the project, serves it with local platform services and rebuilds on every change. Errors are shown in the browser.
maravilla buildCompiles templates and Lua into build/ (lists the event handlers, workflows and MCP tools it found).
maravilla previewServes build/ exactly as production does.
maravilla checkVerifies 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").