Learning Lustre: Type-safe frontend development
摘要
文章记录作者在实习中首次做前端开发的经历,由于不想使用 JavaScript 的生态复杂性,转而尝试用 Gleam 的 Lustre 框架。 内容主要介绍 Lustre 的基本思想:它是一个用于 Gleam 的声明式函数式前端框架,强调简化设计与“尽量只有一种做事方式”。 核心部分讲解 MVU(Model-View-Update)架构:Model 表示应用状态,View 是纯函数负责根据状态渲染 UI,Update 通过模式匹配消息(message)来更新状态。消息用于描述状态变化,通常用 Gleam 的自定义类型定义。 文章还说明了应用结构与路由状态的组织方式(如 Model 中存 page 和 route,页面各自拥有 view/update 逻辑),以及通过 navigation interception 将路由变化转为消息处理。 最后提到使用 Lustre dev tools 编译项目并打包 CSS/JS/HTML,再由后端提供服务,从而构建完整前端应用。
荐读理由
文章围绕 Lustre 的 MVU 架构、状态模型与消息驱动更新机制展开,给出在 Gleam 生态中构建类型安全前端的具体组织方式,但缺少真实工程对比与成熟度验证。你可以据此判断这种“单一方式的 MVU + 强类型前端”是否值得在自己的产品原型中进一步投入探索,而不是仅停留在 React/Vue 常规方案之外的概念了解。
原文
Learning Lustre: Type-safe frontend development with gleam
My experience learning frontend development with gleam during my internship assignment.
May 31, 2026
I recently got hired for an internship! They assigned me to build the frontend for a credit score analysis application, the only problem was that I had no prior experience with frontend development.
I honestly didnt want to use javascript for that, both language and ecosystem felt too messy to me. I didnt want to leave behind the type safety and predictability I had when using gleam, so after some research, I finally decided to finally give Lustre1 a try!
What is Lustre?
Lustre is a declarative, functional framework for building web applications with gleam. It focuses on simplicity by design, and it requires no use of macros or templates.
As mentioned on its documentation:
Modern frontend development is hard and complex. Some of that complexity is necessary, but a lot of it is accidental or comes from having far too many options. Lustre has the same design philosophy as Gleam: where possible, there should be only one way to do things!
The Model-View-Update architecture
Inspired by Elm and erlang, lustre uses message passing2 for managing state, a Lustre application consists of three main parts:
Model: Your application state. It will be passed to your view function in order to determine how the UI will look like.
View: Render your HTML elements. User interactions and external events will produces messages that must be handled by your update function.
Update: Updates your application state. You can pattern match on the messages received by the UI and update the Model.
pub type Message
pub type Model
fn init(_props) {
todo as "build initial model"
}
fn view(model: Model) {
todo as "render your UI"
}
fn update(model: Model, message: Message) {
todo as "update current model"
}
Message
A message describes a state change in your application. They are defined by using a gleam custom type and usually follow the convention: SUBJECT VERB OBJECT.
pub type Message {
/// User updated email field
UserTypedEmail(email: String)
/// User updated password field
UserTypedPassword(password: String)
/// User sent login credentials to the Server
UserClickedSubmit
/// API validated User's credentials
ApiReturnedSession(result: Result(session.Session, rsvp.Error(String)))
}
Model
Your application state is global, not scoped to the current page. You can store page-specific state inside your Model if necessary, that's how I implemented it during my internship project.
Your Model can be defined in your project's root module, and store a page field that for the current page state.
// client.gleam
pub type Model {
Model(
/// Current user
session: session.Session,
/// Current route
route: route.Route,
/// Current Page model
page: page.Page,
/// Selected language
lang: lang.Language,
)
}
The modem package provides the functionality of intercepting navigation to internal links, and sending them to your update function through the provided handler.
You must setup its functionality during initialization.
pub fn init(opts: Init) -> #(Model, effect.Effect(Message)) {
let route = route.parse(opts.uri)
let #(page, effect) = page.init(route)
let init_modem = {
use uri <- modem.init()
let route = route.parse(uri)
// This message will be sent whenever a link is
// intercepted by the `modem` package, and needs to be
// handled properly by your app `update` function.
UserNavigatedTo(route)
}
// `init` functions in Lustre applications must
// provide the initial Model and an side effect to
// run after its done initializing.
let effect = effect.batch([effect, init_modem])
#(Model(route:, page:), effect)
}
Each page can implement its own view and update functions. This way, a page is responsible for its own state management and html rendering.
Both route and page fields from our Model are updated whenever the user navigates around the application.
pub fn update(model: Model, message: Message) {
case model, message {
Model(route: route.Login, page: page.Login(page), ..), LoginMessage(page_message) ->
handle_login_message(model, page, page_message)
Model(route: route.Dashboard, page: page.Dashboard(page), ..), DashboardMessage(page_message) ->
handle_dashboard_message(model, page, page_message)
_, _ -> todo
}
}
View
Your view function is pure, it means that the same Model will always render the same html.
Lustre provides a module for building the skeleton of your page, the coolest part is that its just regular gleam code, all you need to do is import the html module and access its functions.
Here I'm pattern matching on the session field from my application's Model, in order to decide which route this tag leads to.
pub fn view(model: Model) {
case session {
session.Authenticated(..) -> {
let attributes = [
route.href(route.Dashboard),
attribute.class("font-bold bg-primary text-primary-foreground"),
]
html.a(attributes, [
html.text("Dashboard"),
])
}
session.Guest -> {
let attributes = [
route.href(route.Login),
attribute.class("py-2 px-4 rounded-md hstack"),
attribute.class("font-bold bg-primary text-primary-foreground"),
]
html.a(attributes, [
icon.log_in([class("size-4")]),
html.text("Login"),
])
}
session.Pending(..) -> {
let attributes = [
attribute.class("flex gap-2 items-center"),
attribute.class("font-bold bg-primary text-primary-foreground"),
]
html.div(attributes, [
// Render spinner when waiting for Authentication.
html.span([attr.aria_busy(True), attr.data("spinner", "small")], []),
html.p([], [html.text("Loading")]),
])
}
}
}
Update
Your update function takes two arguments:
Your current application Model.
The message being received.
You can pattern match on its arguments to decide what to do next, and update your application state accordingly. Gleam allows pattern match on multiple values.
pub fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {
case model, message {
// NAVIGATION
model, UserNavigatedTo(route:) -> handle_navigation(model, route)
// LANGUAGE SELECTION
model, NavbarMessage(message: navbar.UserSelectedLanguage(lang:)) -> #(
Model(..model, lang:),
effect.none(),
)
// SESSION MANAGEMENT -----------------------------------------------------
//
// If the Server successfully authenticated the User,
// initialize its Session, and redirect them to the correct route.
Model(session: session.Pending(on_success:, ..), ..),
UserRestoredSession(result: Ok(session))
-> {
let route = on_success
let #(page, page_effect) = page.init(route)
let redirect = modem.push(route.path(route), option.None, option.None)
let effect = effect.batch([page_effect, redirect])
#(Model(..model, session: route:, page:), effect)
}
// If it fails, start the Session as a Guest and redirect
// the User accordingly, usually to the Login Page.
Model(session: session.Pending(on_failure:, ..), ..),
UserRestoredSession(result: Error(..))
-> {
let session = session.Guest
let route = on_failure
let #(page, page_effect) = page.init(route)
let redirect = modem.push(route.path(route), option.None, option.None)
let effect = effect.batch([page_effect, redirect])
#(Model(..model, session:, route:, page:), effect)
}
}
}
Since navigating around the application also produces a message, you can easily control what pages can be accessed by a given User.
fn handle_navigation(
model: Model,
route: route.Route,
) -> #(Model, Effect(Message)) {
// Do nothing if the route doesnt change
use <- bool.guard(model.route == route, #(model, effect.none()))
let protected = route.is_protected(route)
let route = case model.session, route {
// If the route require the User to be authenticated,
// redirect them to the Login page.
session.Guest, _ | session.Pending(..), _ if protected -> route.Login
// If the User is *already* authenticated but navigating to
// the Login page, redirect them to Dashboard instead.
session.Authenticated(..), route.Login -> route.Dashboard
_, _ -> route
}
let #(page, effect) = page.init(route)
#(Model(..model, route:, page:), effect)
}
Again, pattern matching is usually all your need to solve most of your problems. Gleam's design focuses on having only one way to do things. It helps keeping your projects simple, and most importantly, predictable.
Compiling the project
After everything is set, you can use lustre_dev_tools to compile your application, bundling all necessary CSS, JS and HTML.
gleam run -m lustre/dev build
# Compiled in 0.08s
# Running lustre/dev.main
# Creating JavaScript bundle...
✅ Bundle successfully built.
✅ HTML generated.
# Copying 6 assets...
✅ Assets copied.
✅ Build complete!
After compiling, you can serve them from your backend and a have a fully functional application. If you already loves how gleam works on the backend, I highly suggest using it on the frontend too! <3
Fun fact, my personal website is also made with Lustre <3
(Wikipedia): In computer science, message passing is a technique for invoking behavior on a computer. The invoking program sends a message to a process and relies on that process and its supporting infrastructure to then select and run some appropriate code.
What I use (2026)
blog
My thoughs on software development
这条对你有帮助吗?