Learning Lustre: Type-safe frontend development with gleam - blog
blog<br>Learning Lustre: Type-safe frontend development with gleam<br>My experience learning frontend development with gleam during my internship assignment.<br>Kacaii
May 31, 2026
1 0
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.<br>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!<br>What is Lustre?<br>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.<br>As mentioned on its documentation:<br>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!<br>The Model-View-Update architecture<br>Inspired by Elm and erlang, lustre uses message passing2 for managing state, a Lustre application consists of three main parts:<br>1.<br>Model: Your application state. It will be passed to your view function in order to determine how the UI will look like.
2.<br>View: Render your HTML elements. User interactions and external events will produces messages that must be handled by your update function.
3.<br>Update: Updates your application state. You can pattern match on the messages received by the UI and update the Model.
pub type Model
pub type Message
fn init(_props) {<br>todo as "build initial model"
fn view(model: Model) {<br>todo as "render your UI"
fn update(model: Model, message: Message) {<br>todo as "update current model"<br>Model<br>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.<br>Your Model can be defined in your project's root module, and store a page field that for the current page state.<br>// client.gleam
pub type Model {<br>Model(<br>/// Current user<br>session: session.Session,<br>/// Current route<br>route: route.Route,<br>/// Current Page model<br>page: page.Page,<br>/// Selected language<br>lang: lang.Language,<br>The modem package provides the functionality of intercepting navigation to internal links, and sending them to your update function through the provided handler.<br>You must setup its functionality during initialization.<br>pub fn init(opts: Init) -> #(Model, effect.Effect(Message)) {<br>let route = route.parse(opts.uri)<br>let page = page.init(route)
let effect = {<br>use uri modem.init()<br>// This message will be sent whenever a link is<br>// intercepted by the `modem` package, and needs to be<br>// handled properly by your app `update` function.<br>UserNavigatedTo(route.parse(uri))
// `init` functions in Lustre applications must<br>// provide the initial Model and an side effect to<br>// run after its done initializing.<br>#(Model(route:, page:), effect)<br>Both route and page fields from our Model are updated whenever the user navigates around the application.<br>Each page can implement its own view and update functions. This way, a page is responsible for its own state management and html rendering.<br>pub fn update(model: Model, message: Message) {<br>case model, message {<br>Model(route: route.Login, page: page.Login(page), ..), LoginMessage(page_message) -><br>handle_login_message(model, page, page_message)
Model(route: route.Dashboard, page: page.Dashboard(page), ..), DashboardMessage(page_message) -><br>handle_dashboard_message(model, page, page_message)
_, _ -> todo<br>View<br>Your view function is pure, it means that the same Model will always render the same html.<br>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.<br>Here I'm pattern matching on the session field from my application's Model, in order to decide which route this tag leads to.<br>pub fn view(model: Model) {<br>case session {<br>session.Authenticated(..) -> {<br>let attributes = [<br>route.href(route.Dashboard),<br>attribute.class("font-bold bg-primary text-primary-foreground"),
html.a(attributes, [<br>html.text("Dashboard"),<br>])
session.Guest -> {<br>let attributes = [<br>route.href(route.Login),<br>attribute.class("py-2 px-4 rounded-md hstack"),<br>attribute.class("font-bold bg-primary text-primary-foreground"),
html.a(attributes, [<br>icon.log_in([class("size-4")]),<br>html.text("Login"),<br>])
session.Pending(..) -> {<br>let attributes = [<br>attribute.class("flex gap-2 items-center"),<br>attribute.class("font-bold bg-primary text-primary-foreground"),
html.div(attributes, [<br>// Render spinner when waiting for Authentication.<br>html.span([attr.aria_busy(True),...