It’s awesome to learn Elm by building a side project, you absolutely should. And maybe you have β perhaps you’ve built a todo app, explored The Elm Architecture, and fallen in love with the reliability and maintainability that comes with functional programming and strong types.
But come Monday morning, you’re back to wrestling with JavaScript bugs, runtime errors, and the nagging feeling that there has to be a better way. The problem isn’t that Elm isn’t ready for production work β it absolutely is. The problem is convincing your team, your boss, or your organization to take the leap. And when you’re thinking about wholesale replacement, your boss’s skepticism might be quite healthy. But here’s the thing: if you reduce the scope, you can also reduce the risk and buy-in required. Instead of “let’s rebuild our entire app in Elm,” what if the conversation was “let’s try Elm for this one small widget”? Suddenly, the stakes drop dramatically.
Companies that use Elm in production usually start with a single component. So if you want to use Elm at work, start with a small experiment. Do people think it is nice? Do more! Do people think it sucks? Do less!
β Jason O’Neil, author of react-elm-components
This is exactly what I did on this very blog, as a proof of concept. While my site is built with Hugo (a Go-based static site generator), I’ve successfully integrated an Elm-powered testimonials carousel that fetches data, manages state, and provides smooth interactions. Let me show you how starting small can turn employer skepticism into genuine enthusiasm.
The Case for Incremental Adoption Link to heading
The official Elm guide on using Elm at work makes a compelling case for incremental adoption, but let’s be honest β it can be hard to visualize what this looks like in practice. Most examples are either too simple (a counter) or too abstract (theoretical architecture diagrams).
What you need is a real example that shows:
- Minimal integration β How to embed Elm without disrupting your existing setup
- Practical scope β A widget complex enough to be useful but small enough to be manageable
- Clear boundaries β How to handle data flow between Elm and your host application
- Gradual migration β How this approach sets you up for future expansion
Meet My Testimonials Widget Link to heading
Before diving into the implementation, let me show you what we’re building. On the home page of this blog (if you’re on a wide screen), you’ll see a testimonials carousel powered entirely by Elm. I also added it to this very page, so you won’t have to stop your reading to check it out. This small widget
- Fetches testimonial data from a JSON endpoint
- Displays testimonials in a responsive carousel format
- Handles navigation with smooth transitions
- Only renders on specific pages and screen sizes
- Integrates seamlessly with the existing Hugo-generated markup
The widget replaces what used to be an embedded iframe β a perfect example of incremental improvement rather than a full rewrite.
The Widget-First Architecture Link to heading
Here’s how the integration works at a high level:
<!-- I put the following in my Hugo footer (layouts/partials/footer.html) -->
<div id="elm-widget"></div>
<script src="/widget.js"></script>
<script>
Elm.Main.init({
node: document.getElementById("elm-widget"),
flags: window.location.pathname, // passing in pathname to allow routing
});
</script>
The beauty of this approach is its simplicity. Your host application (Hugo, in my case) just needs to:
- Provide a DOM element for Elm to mount to
- Compile and include the Elm JavaScript output
- Initialize the Elm application with any required data (the pathname in this example, because I only show the carousel on certain paths)
That’s it. No complex build system integration, no framework-specific adapters, no architectural rewrites.
The Elm Implementation Link to heading
Let’s examine the actual Elm code that powers this widget. The main application is surprisingly simple:
module Main exposing (..)
import Browser
import Html exposing (..)
import Testimonials
main : Program String Model Msg
main =
{- I'm using Browser.element instead of Browser.application to control
only _part_ of the DOM with Elm. Browser.sandbox would also often suffice, but
it doesn't support HTTP requests. -}
Browser.element
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
{- For now I either render Testimonials (the carousel) or nothing,
but this could easily grow to view other things on other routes.
Perhaps the "Subscribe" input/form could be Elm?
-}
type Model
= Testimonials Testimonials.Model
| None
init : String -> ( Model, Cmd Msg )
init path =
-- in effect: show testimonials for "/" and "/hire" routes, and for this very post
if Testimonials.showForPath path then
Testimonials.init ()
-- Map the Testimonials specific model and commands to match the Main ones
|> Tuple.mapBoth Testimonials (Cmd.map TestimonialsMsg)
else
( None, Cmd.none )
The key insight here is that our main module acts as a super-simple router that decides whether to show the testimonials widget based on the current path. This pattern scales beautifully β as you add more Elm widgets, you can expand this router to handle multiple components.
The Testimonials Module Link to heading
The actual testimonials functionality lives in a separate module:
module Testimonials exposing (Model, Msg, init, showForPath, update, view)
type Model
= Failure
| Loading
| Success (List Testimonial) Int
init : () -> ( Model, Cmd Msg )
init () =
( Loading, getTestimonials )
activePaths : Set.Set String
activePaths =
Set.fromList [ "", "/", "/hire", "/posts/starting-small-with-elm-a-widget-approach" ]
showForPath : String -> Bool
showForPath path =
activePaths |> Set.member path
This demonstrates several important patterns:
- Clear state modeling β The
Model
type explicitly represents the three possible states: loading, error, or success with data - Configuration-driven behavior β Which pages show testimonials is controlled by data (path in this instance), not scattered conditionals
- Pure functions β The
showForPath
function is completely predictable and testable - Separated concerns β Data fetching, state management, and rendering are cleanly separated
Migrating testimonials data? Link to heading
Like I said, I used to have that iframe (from testemonials.to), and while I’m definitely ditching that I’d like to keep the data I’ve collected. This is literally how I migrated: I document.querySelector
ed the element containing all existing testimonials and sent the innerHTML
to GPT-4o with instructions to make pretty JSON matching my Elm model. Simple as that. See, I’m not against Automating The Boring Stuff (the actual name of my last AI-related talk @ EnsΕ), I just hate letting LLMs have all the fun of the actual coding.
Handling Data and HTTP Link to heading
One of Elm’s many strengths is how it handles side effects like HTTP requests (ports
is another way Elm deals with the outside world, read more about that here). Anyway, here’s how the testimonials widget fetches data:
getTestimonials : Cmd Msg
getTestimonials =
Http.get
{ url = "/testimonials.json"
, expect = Http.expectJson GotTestimonials testimonialsDecoder
}
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case ( model, msg ) of
( _, GotTestimonials (Ok testimonials) ) ->
( Success testimonials 0, Cmnd.none)
( _, GotTestimonials (Err _) ) ->
( Failure, Cmd.none )
Compare this to typical JavaScript approaches:
- No promise chains or async/await complexity β Commands are values that describe what you want to do
- Explicit error handling β The
Result
type forces you to handle both success and failure cases - No runtime errors β If it compiles, it won’t throw exceptions (HTTP requests can fail, and Stuffβ’ can happen, but it all be predictably handled)
- Testable β You can test your update function by passing it messages directly
Styling Without Conflicts Link to heading
One challenge with widget-based approaches is styling conflicts. Since this Elm widget lives inside a Hugo site with its own CSS, I needed to avoid dependencies on external stylesheets. The solution? Inline styles with careful encapsulation. Granted, I have started down the rabbit hole of changing the overall styling of this blog, as you may have noticed, but to keep this example clear I gave myself the restraint of not depending on any (subject-to-change!) existing classes:
testimonialEntry : Bool -> Testimonial -> Html Msg
testimonialEntry visible testimonial =
let
conditionalStyles = {- implementation details -}
in
Html.div
([ Attributes.style "transition-property" "all"
, Attributes.style "transition-timing-function" "ease-out"
, Attributes.style "transition-duration" "0.4s"
, Attributes.style "border-radius" "2rem"
, Attributes.style "background" "rgba(127,127,127,0.1)"
-- ... more styles
]
++ conditionalStyles
)
[ -- content ]
While not as clean as using a CSS framework, this approach guarantees that the widget won’t break the host application’s styles (or vice versa). As you grow your Elm usage, you can migrate to more sophisticated styling solutions. If my blog theme were built on Tailwind, this would all be a pretty one-liner, though… π€€
Doh…
Responsive Behavior Link to heading
A fun challenge when committing to not relying on external CSS is hiding/showing based on screen size. Rather than relying on CSS media queries, my beloved widget uses a CSS clamp()
function to hide the entire widget on smaller screens.
I bet you haven’t seen one of these in the wild, and I don’t really think you should try the following at home either:
hideOnBreakpoint : String -> Html msg -> Html msg
hideOnBreakpoint breakpoint content =
let
clampStyle =
"clamp(10px, calc((100vw - " ++ breakpoint ++ ") * 1000), 10000px)"
in
Html.div
[ Attributes.style "max-width" clampStyle
, Attributes.style "max-height" clampStyle
, Attributes.style "overflow" "hidden"
]
[ content ]
-- And then you simply pipe your content like this `|> hideOnBreakpoint 600px`
This might look hacky, but it’s actually quite elegant β the widget completely disappears below 600px width, which is exactly what I wanted for this use case and it really is. Β―\(γ)/Β―
The Build Pipeline Link to heading
Getting Elm integrated into an existing build pipeline is surprisingly straightforward. In my case, I added these simple npm scripts:
{
"scripts": {
"build": "elm make src/Main.elm --output=static/widget.js --optimize",
"start": "hugo build -D && concurrently \"http-server ./public\" \"elm-watch hot\""
}
}
For development, elm-watch
provides hot reloading. For production, the standard elm make
command outputs a single JavaScript file that Hugo copies to the final site. No complex webpack configurations or framework-specific build tools required.
If you’re already using React, you can even use react-elm-components to get a head start!
Why This Approach Works Link to heading
Starting with a small widget like this testimonials carousel provides several advantages:
1. Low Risk Link to heading
If the Elm widget breaks, it doesn’t take down your entire application. The worst-case scenario is a missing testimonials section β hardly catastrophic.
2. Learning by Doing Link to heading
You get hands-on experience with Elm’s core concepts (Model-View-Update, commands, subscriptions) in a real-world context without the pressure of migrating critical functionality.
3. Clear Success Metrics Link to heading
You can easily measure the benefits: Does the widget perform better? Is it more reliable? Is the code easier to understand and modify? The answers become obvious quickly.
4. Foundation for Growth Link to heading
Once you have the basic integration working, adding more Elm widgets becomes progressively easier. The build pipeline is established, the team understands the patterns, and the confidence is built.
What’s Next? Link to heading
Having successfully integrated one Elm widget, I’m already thinking about what could be next. I’ve done Markdown rendering in Elm before, perhaps I could use Elm for rendering posts and pages as well? Or perhaps I’ll replace the share buttons with something homecooked? Whatever small problem you decide to solve with Elm, I’m sure there are other problems equally suited. Speaking of which, Elm Programming: Solving Complex Problems with Simple Functions is a great read on that more general topic.
The beauty of this approach is that each step is optional and reversible. You’re not making a bet-the-company decision β you’re making small, measured investments that compound over time. And you’re learning!
Getting Started Today Link to heading
If you want to try this approach in your own application, here’s a roadmap:
- Identify a small widget in your current application that has interesting state management
- Set up a minimal Elm build that outputs to a single JavaScript file
- Create a simple Elm application that mounts to a DOM element and accepts flags
- Implement your widget using The Elm Architecture
- Integrate it by replacing the existing widget (or even static html or whatever) with your Elm version
Start small, prove the value, and expand from there.
Conclusion Link to heading
Elm doesn’t have to be an all-or-nothing decision. By starting with small, isolated widgets, you can:
- Gain practical experience with functional programming concepts
- Improve specific parts of your application with better reliability and maintainability
- Build team confidence and expertise gradually
- Create a foundation for larger migrations if and when they make sense
The testimonials widget on this blog is proof that this approach works in practice.
In the case of Elm, the first small step might just be a single widget.
If you’re interested in exploring this approach further, check out the complete source code for this blog’s Elm widgets. The simplicity might surprise you.