Skip to main content

Advanced Routing

Luat supports dynamic route segments, optional parameters, and catch-all routes for flexible URL handling.

Dynamic Parameters

Use square brackets [param] to create dynamic route segments:

src/routes/blog/[slug]/+page.luat

This matches any URL like /blog/hello-world, /blog/my-first-post, etc.

Accessing Parameters

Parameters are available via ctx.params in +page.server.lua:

-- src/routes/blog/[slug]/+page.server.lua
function load(ctx)
local slug = ctx.params.slug

-- Fetch post by slug
local post = {
slug = slug,
title = "Post: " .. slug,
content = "Content for " .. slug
}

return {
title = post.title,
post = post
}
end

Then use the data in your template:

<!-- src/routes/blog/[slug]/+page.luat -->
<article>
<h1>{props.post.title}</h1>
<p>{props.post.content}</p>
</article>

Multiple Parameters

You can have multiple dynamic segments:

src/routes/users/[userId]/posts/[postId]/+page.luat

Matches /users/123/posts/456:

function load(ctx)
local userId = ctx.params.userId -- "123"
local postId = ctx.params.postId -- "456"

return {
userId = userId,
postId = postId
}
end

Optional Parameters

Use double brackets [[param]] for optional segments:

src/routes/blog/[[page]]/+page.luat

This matches:

  • /blog (page is nil)
  • /blog/1 (page is "1")
  • /blog/2 (page is "2")
-- src/routes/blog/[[page]]/+page.server.lua
function load(ctx)
local page = ctx.params.page or "1"
local pageNum = tonumber(page)

return {
currentPage = pageNum,
posts = fetchPosts(pageNum)
}
end

Catch-All Routes

Use [...param] to match any number of path segments:

src/routes/docs/[...path]/+page.luat

This matches:

  • /docs/intro (path is "intro")
  • /docs/guide/routing (path is "guide/routing")
  • /docs/api/v2/users (path is "api/v2/users")
-- src/routes/docs/[...path]/+page.server.lua
function load(ctx)
local path = ctx.params.path or ""
local segments = {}

-- Split path into segments
for segment in string.gmatch(path, "[^/]+") do
table.insert(segments, segment)
end

return {
path = path,
segments = segments,
breadcrumbs = segments
}
end

Route Priority

When multiple routes could match, Luat uses this priority:

  1. Static routes - Exact matches first
  2. Dynamic routes - [param] segments
  3. Optional routes - [[param]] segments
  4. Catch-all routes - [...param] segments

Example with these routes:

src/routes/blog/new/+page.luat        # Static: /blog/new
src/routes/blog/[slug]/+page.luat # Dynamic: /blog/:slug
src/routes/blog/[[page]]/+page.luat # Optional: /blog or /blog/:page
  • /blog/new matches the static route
  • /blog/hello-world matches the dynamic route
  • /blog matches the optional route

Real-World Example

A blog with pagination and dynamic posts:

src/routes/blog/
├── +page.luat # /blog (blog index)
├── +page.server.lua # Load posts list
├── page/
│ └── [num]/
│ ├── +page.luat # /blog/page/2, /blog/page/3
│ └── +page.server.lua
└── [slug]/
├── +page.luat # /blog/my-post
└── +page.server.lua
-- src/routes/blog/+page.server.lua
function load(ctx)
return {
title = "Blog",
posts = fetchPosts(1, 10), -- First page
currentPage = 1,
totalPages = 5
}
end
-- src/routes/blog/page/[num]/+page.server.lua
function load(ctx)
local page = tonumber(ctx.params.num) or 1

return {
title = "Blog - Page " .. page,
posts = fetchPosts(page, 10),
currentPage = page,
totalPages = 5
}
end
-- src/routes/blog/[slug]/+page.server.lua
function load(ctx)
local slug = ctx.params.slug
local post = fetchPostBySlug(slug)

if not post then
-- Return 404 data
return {
title = "Not Found",
error = "Post not found"
}
end

return {
title = post.title,
post = post
}
end

URL Encoding

Dynamic parameters are automatically URL-decoded:

  • /blog/hello%20worldctx.params.slug = "hello world"
  • /search?q=lua%2Brustctx.query.q = "lua+rust"

Parameter Validation

Always validate dynamic parameters in your server code:

function load(ctx)
local id = ctx.params.id

-- Check if ID is a valid number
local numId = tonumber(id)
if not numId or numId < 1 then
return {
error = "Invalid ID",
status = 400
}
end

-- Check if ID exists
local item = findById(numId)
if not item then
return {
error = "Not found",
status = 404
}
end

return { item = item }
end

Fragments

Fragments are partial HTML templates that render without the full page wrapper. They're useful for HTMX-style partial updates where you want to swap just a portion of the page.

The (fragments) Folder

Place fragment templates in a (fragments) subfolder of your route:

src/routes/todos/
├── +page.luat # Full page
├── +page.server.lua # load() + actions
└── (fragments)/ # Fragment templates
├── add.luat
├── delete.luat
└── toggle.luat

Triggering Fragments

Fragments are triggered using the ?/name URL pattern:

URLMethodWhat happens
GET /todosGETRenders +page.luat (full page)
GET /todos?/addGETRuns actions.add, renders (fragments)/add.luat
POST /todos?/addPOSTRuns actions.add, renders (fragments)/add.luat
POST /todos?/deletePOSTRuns actions.delete, renders (fragments)/delete.luat

Both GET and POST (and other methods) can trigger fragments. The action runs first, then the fragment renders with the action's return value as props.

Fragment Naming

Fragments can be method-specific:

  • add.luat - Renders for any method
  • POST-add.luat - Renders only for POST requests (takes precedence)
  • GET-refresh.luat - Renders only for GET requests
(fragments)/
├── add.luat # Any method
├── POST-add.luat # POST only (preferred over add.luat for POST)
├── delete.luat
└── GET-refresh.luat # GET only

Basic Example

-- +page.server.lua
actions = {
add = function(ctx)
local item = create_item(ctx.form.title)
return { item = item }
end,

delete = function(ctx)
delete_item(ctx.form.id)
return { deleted = true }
end
}
<!-- (fragments)/add.luat -->
<li>{props.item.title}</li>
<!-- (fragments)/delete.luat -->
<!-- Empty template removes the element -->

For a complete walkthrough with HTMX integration, see the HTMX & Fragments Guide.

Response Headers

The engine automatically sets x-luat-fragment: true on fragment responses. This tells adapters to return the HTML directly without wrapping it in app.html.

No Fragment: JSON Response

If no matching fragment template exists, the action result is returned as JSON:

{
"todo": {
"id": "123",
"title": "Buy milk"
}
}

This is useful for API-style responses or when you handle the response client-side with JavaScript.