How does elm-pages work?

Is elm-pages magic? Does it use native code or hacks to give me side effects or things like BackendTask? Does my mental model of Elm and pure functions change?

The answer to all of these questions is no!

It might feel a bit like magic to be able to run full-stack Elm. And while it may be a bit of a mental model of how all these pieces fit together, it's all just Elm, plus some code generation, and generating two Elm applications. One backend version that runs at build-time, or on each server-request if it's a server-rendered route. And one frontend version that takes the initial HTML and data provided by the backend app. So it gives you a seamless experience of writing in one context, but this is more a convention and an abstraction than magic. elm-pages does also use the Lamdera compiler to get some automatic Bytes Encoders and Decoders that it uses to communicate between the frontend and backend apps. This doesn't change your guarantees about using Elm, though it is a key piece that makes it seamless to build your elm-pages routes. This is how the data is resolved from the backend app, but is then accessible to the frontend app. Hopefully you'll find it very predictable once you understand this general architecture and set of abstractions.

elm-pages does not change Elm's pure functions. In fact, it heavily relies on this as a basis for its design! It does give you BackendTask which allows you to execute data. It may not seem like it, but this is nothing more than a plain old Elm Custom Type! That Custom Type is handled with some special behavior by the framework to give you a lot of functionality, but it doesn't in any way break the guarantees you know and love from any other Elm application. Think of BackendTask like a Cmd or Effect. It's a special type which you pass up to the runtime to ask it to do something for you. Since Elm code is pure, it can't perform side-effects. BackendTasks will not do anything unless they're passed to the elm-pages framework. Since the framework has its own Model, update, etc., it's able to go perform side-effects to resolve those BackendTasks for you. You can think of it like a wrapper around The Elm Architecture.

The secret to elm-pages is that it creates an Elm app that calls the code you define (in your Route Modules, etc.), wires up routing for you, manages some state in its Model (it's just an Elm app, so it can have its own Model and share some of the things it keeps in there with you - in fact, that's exactly what RouteBuilder.App is, some state that is manged for you by the framework!).

So the main techniques elm-pages uses to give what feels like magic are:

  • elm-pages takes the code you write in your Route Modules and it wires it up to give you routing, etc. with some generated code (also gives you a Route.elm module, for example)
  • elm-pages creates two Elm applications from your Route Modules. One that runs in the browser, one that runs on the server! data and action are never run in the Browser, they are only run from your backend (which could be your build step if it's a pre-rendered route)
  • The app that elm-pages makes to run on your server has a port that it uses. Since this runs in the backend, this runs in a NodeJS context! So that means this port is able to do things like read files, or even run arbitrary NodeJS functions! This is what "myNodeJsFunction" does, elm-pages just tries running myNodeJsFunction from your custom-backend-task.ts file. It transpiles that file with esbuild, which is why you can write it in TypeScript.
  • elm-pages has a few places that accept a BackendTask (like your Route Modules' data and action functions). It takes a BackendTask and is able to use the port in its generated code for the Server app to step through and resolve any data that's needed until the BackendTask is complete. Then it passes that resolved data through to the user code.

That's all it is really! It might feel magical, but it's really just a couple of abstractions that are used over and over again. It's the difference between you (the user) running a port, vs. the framework (elm-pages) using a port internally. But under the hood, elm-pages is just using init, update, Model, etc. BackendTask's are resolved in the Server app that elm-pages generates just using these vanilla Elm things, so it's all just an abstraction that's built on regular Elm!