Framework
dillonkearns/elm-pages avatar
dillonkearns/elm-pages

elm-pages: mixing server-rendered and static Elm routes in one app

Hybrid Elm framework with full-stack and static routes.

696 stars102 forksElmBSD-3-Clause

At a glance

What is it?
elm-pages is a hybrid framework for Elm single-page apps where each route can be server-rendered for request-specific data or pre-rendered at build time as static HTML, and the two kinds coexist in the same application. Its newest work is a terminal UI toolkit and an in-process end-to-end test harness, and its release cadence is a compatibility key rather than a version number.
Who is it for?
Adopt elm-pages if you are writing an Elm application that needs both request-time rendering for user-specific pages and build-time static HTML for the rest, and if you can live with the two-package upgrade dance that the compatibility key enforces. Do not adopt it for a purely static site, where a plain static site generator does the same job with less machinery, and do not adopt it if you need a JavaScript framework, since the whole point of the tool is the Elm runtime.
Can I use it commercially?
Yes. BSD-3-Clause is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 154 days ago.
What is it written in?
Mainly Elm, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 23, 2026, and from our analysis. They are not legal advice.

Editorial analysis

One Elm app, two rendering strategies

elm-pages is a framework for building an Elm single-page app that is able to interface with data from an Elm Backend, and the word hybrid is the key. It allows you to define routes that are either server-rendered, for more dynamic content with user-specific or request-specific data, or pre-rendered at build-time, for generating static HTML files that are hosted through a CDN. You can mix and match the two kinds of routes in your app.

That is a specific and unusual combination. Most single-page application frameworks are client-side only, and most static site generators are build-time only. A site that has a marketing section, a blog and a documentation set wants static output for speed and caching, and a dashboard behind a login wants a server that can render the page for the person asking. The usual answer is two separate systems with a shared design language, or one system that renders everything on the client and gives up the static output.

The framework also has a second life that is easy to miss. elm-pages has a command for running pure Elm scripts with a single command, and the newest release adds a terminal UI toolkit built on that. The docs page for elm-pages Scripts is linked from the README, so the script runner is a documented feature rather than an internal tool.

The topics on the repository cover blog, jamstack, seo, static site generator and Elm, and the package is published both to npm and to the Elm package registry, which is unusual and reflects that this is a build tool that has to line up with an Elm application at compile time.

The compatibility key and the two-package upgrade

The mechanism that most affects how you use this framework is the Compatibility Key. You will see an error if the npm and Elm package do not have a matching Compatibility Key, and the guidance is that it is usually best to upgrade to the latest version of both the Elm and the npm packages when you upgrade. If you want to install versions behind the latest, the key is published for reference, and the current one is 29.

This is a design decision with real consequences. A framework whose compiler half and library half must agree on a number is a framework that cannot support arbitrary version skew between them. That is the point: the generated code and the runtime library have to be built against the same internal contract, and a numeric key is a compact way to assert that. The cost is that you cannot upgrade one without the other, and that a project pinned to older versions is pinned to a specific key.

The repository layout shows how much of this is codegen. There is a codegen/ directory, a generator/ directory, a plugins/ directory and an adapter/ directory, alongside src/. The build script for the generator is a two-step process, elm-codegen install followed by a Lamdera Make invocation that produces elm-pages-codegen.js which is then renamed to a .cjs file. The Elm side is compiled by elm-tooling, and elm.json sits at the root next to the TypeScript tsconfig.json and a jsconfig.json for the JavaScript parts.

There are two separate changelogs, CHANGELOG-ELM.md and CHANGELOG-NPM.md, which is a small confirmation of the two-package model. And the repository carries no GitHub releases, so the version history is read from those changelogs rather than from tags.

The TUI toolkit turns elm-pages scripts into terminal apps

The headline addition in 12.2 is Tui, which builds interactive terminal applications as elm-pages scripts. The model is a TEA-style init, update and view triple that renders styled text to the terminal and responds to keyboard, mouse, paste, resize and time events.

The interesting design choice is how the same script runs in two environments. Tui.programOrScript lets the same script behave interactively in a TTY and as a plain BackendTask under CI or when its output is piped. That is the same philosophy the framework applies to routes, where a component is written once and the framework decides whether it renders on a server or at build time. Here, the same program decides whether it is interactive or a batch job based on whether a terminal is attached.

This is what makes the terminal support more than a toy. A script that can render a live interface for a developer and produce plain output for a pipeline is a script you can put in a build step, and the decision is made by the environment rather than by a flag the caller has to remember to pass.

The examples directory carries the demonstration code. There is an end-to-end example with a script test at examples/end-to-end/script/tests/TuiStarsTests.elm, and the README points at examples/smoothies/tests/ and examples/todos/tests/ for the route tests.

Route tests that run in process, with no browser

The second 12.2 addition is Test.PagesProgram, an end-to-end test harness for elm-pages routes. The description is unusually specific: drive the real framework runtime through form submissions, optimistic UI, concurrent fetchers, redirects, session cookies and error pages with simulated HTTP and custom ports. It is deterministic, in-process, and needs no browser. The inspiration is elm-program-test, and that inspiration explains the shape of it.

The point of this is that an Elm application has no headless DOM to test against in the usual sense, so end-to-end testing in Elm has traditionally meant a real browser. That is slow and awkward in CI. Here, the runtime is driven directly, so a test can submit a form, let the optimistic UI update, resolve the fetcher, follow the redirect and assert on the final page, all inside one process with simulated HTTP responses.

The scope is worth noting. The list of behaviours under test includes session cookies and error pages, which are the parts of a full-stack framework most likely to break silently, and the custom ports and simulated HTTP mean the test controls the network boundary rather than escaping to it. That is a meaningful difference from a browser test that hits a local server.

There is a Test.Tui module for the terminal side, which simulates keypresses and mouse events, resolves pending BackendTasks with the Test.BackendTask simulators, and asserts on screen output. And the visual test runner ties the two halves together: elm-pages dev serves page tests in the browser at /_tests, elm-pages test --visual steps through TUI tests in the terminal, and elm-pages test runs headlessly.

Running the examples and the local toolchain

The examples folder is unusually large and is the best documentation in the repository, with entries for a hello world, pokedex, blog engine, coffee, docs, routing, slides, trails, repos, hackernews, todos, todos-tea, smoothies, base-path, escaping, static regions in three variants, and an end-to-end directory. The examples folder also includes the source of the elm-pages.com site itself.

The README gives one instruction that matters more than it looks. Use git clone --recurse-submodules so that there are no missing files when you build the examples. The presence of a .gitmodules file at the root confirms that some example content lives in submodules, and an ordinary clone produces a tree that fails to build in a way that looks like a framework bug and is not one.

The npm scripts show the local workflow, and the two you will use first are these:

json
    "start": "cd examples/end-to-end && npm ci && npx elm-pages dev",
    "test": "./test.sh",

The start script changes into the end-to-end example, runs npm ci there and then npx elm-pages dev, so the dev server is started against a real example rather than a toy. The test script calls ./test.sh at the repository root, and and there is a separate script that installs and tests the escaping and base-path examples in sequence, which is the pattern you would expect for a framework where the generated output is part of the contract.

Other scripts cover the rest. The cypress script starts the dev server and runs cypress run against it, so browser level testing is still available on top of the in-process harness. The review script runs elm-review for static analysis, format runs prettier, and there are two publish scripts, publish-prep.sh and publish-validate.sh, for the Elm and npm packages respectively. The Netlify deploy badge and the netlify.toml at the root show that the project itself is deployed on Netlify, which is also the target the framework is designed for.

What the hybrid model costs you

The first limitation is that you have to think about two execution environments, not one. A route that is pre-rendered runs at build time with no request, and a route that is server-rendered runs on every request. Code that ends up shared between them has to work in both, and the moment you need the request, the page cannot be static. The examples directory includes static-regions, static-regions-esvd and static-regions-demo, which suggests that the boundary between static and dynamic regions within one app is a real design question the project keeps working on rather than a solved problem.

The second limitation is the coupling of the two packages, discussed above, which is a permanent tax on your dependency management rather than a one-off upgrade cost.

The third is ecosystem size. Elm itself is a small language with a small library ecosystem, and elm-pages is one project maintained in that space. The npm dependency list in the manifest shows a stack that leans on Vite, esbuild, terser, connect, chokidar, busboy, memfs, gray-matter and devcert, which is a lot of machinery for a framework whose selling point is a small language. That is not an argument against it, but it is a fact about who the project is for.

The real alternative for a static site is a dedicated static site generator, and the difference in approach is exactly the hybrid part. A static site generator renders everything to files at build time, and a server framework renders everything per request. elm-pages lets a single Elm application contain both, which is the right answer for a site with a logged-in section, and is extra machinery for a site that does not. If your site is entirely public, the simpler path is the one that writes HTML files and nothing else. If your site has a per-user area, you need the server rendering anyway, and at that point the question is not whether to have a hybrid framework but whether to have one in Elm at all, since the same split is available in other languages with more libraries behind them.

Editorial conclusion

Adopt elm-pages if you are writing an Elm application that needs both request-time rendering for user-specific pages and build-time static HTML for the rest, and if you can live with the two-package upgrade dance that the compatibility key enforces. Do not adopt it for a purely static site, where a plain static site generator does the same job with less machinery, and do not adopt it if you need a JavaScript framework, since the whole point of the tool is the Elm runtime. Before you start, check that the Elm package and the npm package you install share a compatibility key, because a mismatch is a hard error, and read the what's-new notes for 12.2 to see whether the TUI and testing additions matter for your project.

Frequently asked questions

What is elm-pages?

It is a framework for building an Elm single-page app that can interface with data from an Elm Backend, and it is hybrid: routes can be server-rendered for request-specific or user-specific data, or pre-rendered at build time as static HTML hosted through a CDN, and the two kinds can be mixed in one app. The package is published to both npm and the Elm package registry.

What is the elm-pages Compatibility Key?

It is a number that must match between the npm package and the Elm package, and you get an error if it does not. The current Compatibility Key is 29, and the guidance is to upgrade both the Elm and the npm packages to their latest versions together when you upgrade.

What is the Tui module in elm-pages 12.2?

Tui builds interactive terminal applications as elm-pages scripts, with a TEA-style init, update and view that renders styled text and responds to keyboard, mouse, paste, resize and time events. Tui.programOrScript lets the same script run interactively in a TTY or as a plain BackendTask under CI or when its output is piped.

How does elm-pages test routes without a browser?

Test.PagesProgram is an end-to-end test harness that drives the real framework runtime in process with simulated HTTP and custom ports, covering form submissions, optimistic UI, concurrent fetchers, redirects, session cookies and error pages. It is deterministic and needs no browser, and elm-pages dev serves page tests in the browser at /_tests for visual work.

How do I build the elm-pages examples?

Use git clone --recurse-submodules so that the example files are present, since some example content lives in submodules. The npm start script installs the end-to-end example and runs npx elm-pages dev, and the repository also has a quick start repo at dillonkearns/elm-pages-starter with a live Netlify site.

Official sources

  1. dillonkearns/elm-pages on GitHub
  2. Issues
  3. License: BSD-3-Clause
  4. Project website
  5. README
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/dillonkearns-elm-pages.svg)](https://hysenlabs.com/projects/dillonkearns-elm-pages)

Was this useful?