{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreic4s2wgkvtipseq5n2o2lfjf5iojzgea7p3cv7gq4zohf4k63scfy",
    "commit": {
      "cid": "bafyreiausp7hbwxbmvoyfo6ognbtg5swqrznvemwpgh3kxcxlkrfkjopfa",
      "rev": "3mn6n3qmsna2y"
    },
    "uri": "at://did:plc:dvyyslcz7bicxdumlccviqyv/app.bsky.feed.post/3mn6n3qkkrk2b",
    "validationStatus": "valid"
  },
  "content": {
    "$type": "pub.leaflet.content",
    "pages": [
      {
        "$type": "pub.leaflet.pages.linearDocument",
        "blocks": [
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 144,
                    "byteStart": 133
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 163,
                    "byteStart": 149
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#link",
                      "uri": "https://lustre.hexdocs.pm/index.html"
                    }
                  ],
                  "index": {
                    "byteEnd": 252,
                    "byteStart": 246
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#footnote",
                      "contentFacets": [
                        {
                          "features": [
                            {
                              "$type": "pub.leaflet.richtext.facet#link",
                              "uri": "https://site.kacaii.dev/"
                            }
                          ],
                          "index": {
                            "byteEnd": 29,
                            "byteStart": 22
                          }
                        }
                      ],
                      "contentPlaintext": "Fun fact, my personal website is also made with Lustre <3",
                      "footnoteId": "019e7b6a-e49c-700c-ae2b-1eb69512bebe"
                    }
                  ],
                  "index": {
                    "byteEnd": 253,
                    "byteStart": 252
                  }
                }
              ],
              "plaintext": "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 Lustre* a try!"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.horizontalRule"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 2,
              "plaintext": "What is Lustre?"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#link",
                      "uri": "https://lustre.hexdocs.pm/index.html#philosophy"
                    }
                  ],
                  "index": {
                    "byteEnd": 33,
                    "byteStart": 20
                  }
                }
              ],
              "plaintext": "As mentioned on its documentation:"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.blockquote",
              "plaintext": "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!"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.horizontalRule"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 2,
              "plaintext": "The Model-View-Update architecture"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#footnote",
                      "contentPlaintext": "(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.",
                      "footnoteId": "019e7b43-1805-700c-add6-fff657d237ef"
                    }
                  ],
                  "index": {
                    "byteEnd": 56,
                    "byteStart": 55
                  }
                }
              ],
              "plaintext": "Inspired by Elm and erlang, lustre uses message passing* for managing state, a Lustre application consists of three main parts:"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.orderedList",
              "children": [
                {
                  "$type": "pub.leaflet.blocks.orderedList#listItem",
                  "content": {
                    "$type": "pub.leaflet.blocks.text",
                    "facets": [
                      {
                        "features": [
                          {
                            "$type": "pub.leaflet.richtext.facet#bold"
                          }
                        ],
                        "index": {
                          "byteEnd": 5,
                          "byteStart": 0
                        }
                      }
                    ],
                    "plaintext": "Model: Your application state. It will be passed to your view function in order to determine how the UI will look like."
                  }
                },
                {
                  "$type": "pub.leaflet.blocks.orderedList#listItem",
                  "content": {
                    "$type": "pub.leaflet.blocks.text",
                    "facets": [
                      {
                        "features": [
                          {
                            "$type": "pub.leaflet.richtext.facet#bold"
                          }
                        ],
                        "index": {
                          "byteEnd": 4,
                          "byteStart": 0
                        }
                      }
                    ],
                    "plaintext": "View: Render your HTML elements. User interactions and external events will produces messages that must be handled by your update function."
                  }
                },
                {
                  "$type": "pub.leaflet.blocks.orderedList#listItem",
                  "content": {
                    "$type": "pub.leaflet.blocks.text",
                    "facets": [
                      {
                        "features": [
                          {
                            "$type": "pub.leaflet.richtext.facet#bold"
                          }
                        ],
                        "index": {
                          "byteEnd": 6,
                          "byteStart": 0
                        }
                      }
                    ],
                    "plaintext": "Update: Updates your application state. You can pattern match on the messages received by the UI and update the Model."
                  }
                }
              ],
              "startIndex": 1
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "pub type Model\n\npub type Message\n\nfn init(_props) {\n  todo as \"build initial model\"\n}\n\nfn view(model: Model) {\n  todo as \"render your UI\"\n}\n\nfn update(model: Model, message: Message) {\n  todo as \"update current model\"\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 3,
              "plaintext": "Model"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 99,
                    "byteStart": 80
                  }
                }
              ],
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#highlight"
                    }
                  ],
                  "index": {
                    "byteEnd": 73,
                    "byteStart": 69
                  }
                }
              ],
              "plaintext": "Your Model can be defined in your project's root module, and store a page field that for the current page state."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "// client.gleam\n\npub type Model {\n  Model(\n    /// Current user\n    session: session.Session,\n    /// Current route\n    route: route.Route,\n    /// Current Page model\n    page: page.Page,\n    /// Selected language\n    lang: lang.Language,\n  )\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#link",
                      "uri": "https://hexdocs.pm/modem"
                    }
                  ],
                  "index": {
                    "byteEnd": 9,
                    "byteStart": 4
                  }
                }
              ],
              "plaintext": "The modem package provides the functionality of intercepting navigation to internal links, and sending them to your update function through the provided handler."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "You must setup its functionality during initialization."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "pub fn init(opts: Init) -> #(Model, effect.Effect(Message)) {\n  let route = route.parse(opts.uri)\n  let page = page.init(route)\n\n  let effect = {\n    use uri <- modem.init()\n    // This message will be sent whenever a link is\n    // intercepted by the `modem` package, and needs to be\n    // handled properly by your app `update` function.\n    UserNavigatedTo(route.parse(uri))\n  }\n\n  // `init` functions in Lustre applications must\n  // provide the initial Model and an side effect to\n  // run after its done initializing. \n  #(Model(route:, page:), effect)\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 10,
                    "byteStart": 5
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 19,
                    "byteStart": 15
                  }
                }
              ],
              "plaintext": "Both route and page fields from our Model are updated whenever the user navigates around the application."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 36,
                    "byteStart": 32
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 47,
                    "byteStart": 41
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#italic"
                    }
                  ],
                  "index": {
                    "byteEnd": 119,
                    "byteStart": 103
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#italic"
                    }
                  ],
                  "index": {
                    "byteEnd": 138,
                    "byteStart": 124
                  }
                }
              ],
              "plaintext": "Each page can implement its own view and update functions. This way, a page is responsible for its own state management and html rendering."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "pub fn update(model: Model, message: Message) {\n  case model, message {\n    Model(route: route.Login, page: page.Login(page), ..), LoginMessage(page_message) ->\n      handle_login_message(model, page, page_message)\n\n    Model(route: route.Dashboard, page: page.Dashboard(page), ..), DashboardMessage(page_message) ->\n      handle_dashboard_message(model, page, page_message)\n\n    _, _ -> todo\n  }\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 3,
              "plaintext": "View"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 26,
                    "byteStart": 22
                  }
                }
              ],
              "plaintext": "Your view function is pure, it means that the same Model will always render the same html."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 117,
                    "byteStart": 90
                  }
                }
              ],
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#link",
                      "uri": "https://tour.gleam.run/flow-control/case-expressions/"
                    }
                  ],
                  "index": {
                    "byteEnd": 25,
                    "byteStart": 9
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 40,
                    "byteStart": 33
                  }
                },
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#highlight"
                    }
                  ],
                  "index": {
                    "byteEnd": 115,
                    "byteStart": 112
                  }
                }
              ],
              "plaintext": "Here I'm pattern matching on the session field from my application's Model, in order to decide which route this <a> tag leads to."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "pub fn view(model: Model) {\n  case session {\n    session.Authenticated(..) -> {\n      let attributes = [\n        route.href(route.Dashboard),\n        attribute.class(\"font-bold bg-primary text-primary-foreground\"),\n      ]\n\n      html.a(attributes, [\n        html.text(\"Dashboard\"),\n      ])\n    }\n\n    session.Guest -> {\n      let attributes = [\n        route.href(route.Login),\n        attribute.class(\"py-2 px-4 rounded-md hstack\"),\n        attribute.class(\"font-bold bg-primary text-primary-foreground\"),\n      ]\n\n      html.a(attributes, [\n        icon.log_in([class(\"size-4\")]),\n        html.text(\"Login\"),\n      ])\n    }\n\n    session.Pending(..) -> {\n      let attributes = [\n        attribute.class(\"flex gap-2 items-center\"),\n        attribute.class(\"font-bold bg-primary text-primary-foreground\"),\n      ]\n\n      html.div(attributes, [\n        // Render spinner when waiting for Authentication.\n        html.span([attr.aria_busy(True), attr.data(\"spinner\", \"small\")], []),\n        html.p([], [html.text(\"Loading\")]),\n      ])\n    }\n  }\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 3,
              "plaintext": "Update"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "Your update function takes two arguments:"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.orderedList",
              "children": [
                {
                  "$type": "pub.leaflet.blocks.orderedList#listItem",
                  "content": {
                    "$type": "pub.leaflet.blocks.text",
                    "plaintext": "Your current application Model."
                  }
                },
                {
                  "$type": "pub.leaflet.blocks.orderedList#listItem",
                  "content": {
                    "$type": "pub.leaflet.blocks.text",
                    "plaintext": "The message being received."
                  }
                }
              ],
              "startIndex": 1
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#bold"
                    }
                  ],
                  "index": {
                    "byteEnd": 158,
                    "byteStart": 113
                  }
                }
              ],
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "pub fn update(model: Model, message: Message) -> #(Model, Effect(Message)) {\n  case model, message {\n    // NAVIGATION \n    model, UserNavigatedTo(route:) -> handle_navigation(model, route)\n\n    // LANGUAGE SELECTION\n    model, NavbarMessage(message: navbar.UserSelectedLanguage(lang:)) -> #(\n      Model(..model, lang:),\n      effect.none(),\n    )\n\n    // SESSION MANAGEMENT  -----------------------------------------------------\n    //\n    // If the Server successfully authenticated the User,\n    // initialize its Session, and redirect them to the correct route.\n    Model(session: session.Pending(on_success:, ..), ..),\n      UserRestoredSession(result: Ok(session))\n    -> #(\n      Model(..model, session:, route: on_success, page: page.init(on_success)),\n      modem.push(route.path(on_success), option.None, option.None),\n    )\n\n    // If it fails, start the Session as a Guest and redirect\n    // the User accordingly, usually to the Login Page.\n    Model(session: session.Pending(on_failure:, ..), ..),\n      UserRestoredSession(result: Error(..))\n    -> {\n      let session = session.Guest\n      let route = on_failure\n\n      let model = Model(..model, route:, page: page.init(route), session:)\n      #(model, modem.push(route.path(route), option.None, option.None))\n    }\n  }\n} ",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "Since navigating around the application also produces a message, you can easily control what pages can be accessed by a given User."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "gleam",
              "plaintext": "fn handle_navigation(\n  model: Model,\n  route: route.Route,\n) -> #(Model, Effect(Message)) {\n  // Do nothing if the route doesnt change\n  use <- bool.guard(model.route == route, #(model, effect.none()))\n  let protected = route.is_protected(route)\n\n  let route = case model.session, route {\n    // If the route require the User to be authenticated,\n    // redirect them to the Login page.\n    session.Guest, _ | session.Pending(..), _ if protected -> route.Login\n    // If the User is *already* authenticated but navigating to\n    // the Login page, redirect them to Dashboard instead.\n    session.Authenticated(..), route.Login -> route.Dashboard\n\n    _, _ -> route\n  }\n\n  let page = page.init(route)\n  #(Model(..model, route:, page:), effect.none())\n}",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "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."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.header",
              "level": 2,
              "plaintext": "Compiling the project"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "facets": [
                {
                  "features": [
                    {
                      "$type": "pub.leaflet.richtext.facet#link",
                      "uri": "https://lustre-dev-tools.hexdocs.pm/lustre/dev.html"
                    }
                  ],
                  "index": {
                    "byteEnd": 53,
                    "byteStart": 37
                  }
                }
              ],
              "plaintext": "After everything is set, you can use lustre_dev_tools to compile your application, bundling all necessary CSS, JS and HTML."
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.code",
              "language": "fish",
              "plaintext": "gleam run -m lustre/dev build\n#  Compiled in 0.08s\n#   Running lustre/dev.main\n#  Creating JavaScript bundle...\n✅ Bundle successfully built.\n✅ HTML generated.\n#  Copying 6 assets...\n✅ Assets copied.\n✅ Build complete!",
              "syntaxHighlightingTheme": "catppuccin-mocha"
            }
          },
          {
            "$type": "pub.leaflet.pages.linearDocument#block",
            "block": {
              "$type": "pub.leaflet.blocks.text",
              "plaintext": "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"
            }
          }
        ],
        "id": "019e6604-62ea-777d-8c94-ff20c08310cb"
      }
    ]
  },
  "description": "My experience learning frontend development with gleam during my internship assignment.",
  "path": "/3mn6n3lvibc2b",
  "publishedAt": "2026-05-31T22:40:17.168Z",
  "site": "at://did:plc:dvyyslcz7bicxdumlccviqyv/site.standard.publication/3me4v7bcg322k",
  "tags": [
    "gleam",
    "lustre"
  ],
  "title": "Learning Lustre: Type-safe frontend development with gleam"
}