Route / render / island.

How elm-ssr fits together: Elm route modules, the generated router, the Worker runtime, and interactive islands.

Runtime pulse

Routes render HTML on the server.

SSR HTML

A route returns a Document. If that document contains no island marker, elm-ssr does not add the island script.

Generated router

Route files become request handlers.

The elm-ssr build scans Routes/, maps file paths to URLs, and generates the Elm router used by the Worker.

Generated route lattice diagram
Local sequence
bun run build:styles
bun elm-ssr build
wrangler dev

The app builds its stylesheet, runs the elm-ssr generator, then starts the Worker locally.

  • The generated router dispatches every route defined in Routes/ to its Elm module.
  • The Worker serves the stylesheet, island runtime, and static assets alongside page responses.
  • Framework endpoints (/api/routes, /api/render, /health) are available on every elm-ssr app.
Architecture diagram

The Worker is the release artifact.

The Worker receives the request, calls the generated Elm app, serves the stylesheet and island runtime, and exposes the route catalog.

Build pipeline architecture diagram
Concrete examples

The pieces in the diagram, as code.

The architecture is easier to read when every concept has room: a short explanation beside a readable code sample.

Example

Route module

Every route exposes a page Loader for GET/HEAD and an action for POST-style work.

page : Request -> Loader (Document Never)
page _ =
    Loader.succeed view

action : Request -> Action (Document Never)
action _ =
    Action.fail 405 "Method not allowed"
Example

Server loader

Loaders describe server work. The Worker performs the effect and resumes Elm with decoded data.

page _ =
    Loader.fetchJson
        { url = "https://api.example.com/status"
        , decoder = statusDecoder
        }
        |> Loader.map viewStatus
Example

Route action

Actions can validate submitted form data, reuse Loader effects, then redirect after success.

action request =
    case Route.formValue "email" request of
        Nothing ->
            Action.fail 422 "Email is required"

        Just email ->
            Action.fromLoader (saveSubscriber email)
                |> Action.andThen (\_ -> Action.redirect "/thanks")
Example

Island marker

The page emits a marker with flags and fallback markup. The browser runtime mounts the matching island app there.

Island.embed "RuntimePulse"
    { encodeFlags = encodeFlags
    , fallback = fallback
    , id = Just "runtime-pulse"
    }
    flags
Example

Worker runtime

createWorkerApp receives the generated Elm module, route catalog, stylesheet, island bundle, and effect runner.

export const worker = createWorkerApp({
  elmModule: Main,
  stylesheet,
  routes,
  islands,
  islandsBundle,
  createFlags,
  effects
});
Example

Cache effect

Loaders can describe a cache lookup, then fall back to another Loader when the cache misses.

cachedStatus : Loader Status
cachedStatus =
    Loader.cacheGet { key = "status", decoder = statusDecoder }
        |> Loader.andThen
            (\cached ->
                case cached of
                    Just status -> Loader.succeed status
                    Nothing -> fetchAndStoreStatus
            )
Example

SQL action

Actions can reuse Loader effects. This keeps form handling declarative while the Worker executes the SQL.

saveSubscriber : String -> Loader { rowsAffected : Int }
saveSubscriber email =
    Loader.execute
        { sql = "insert into subscribers(email) values (?)"
        , params = [ Encode.string email ]
        }
Example

JSON response

An Action can return JSON directly instead of a document or redirect.

action request =
    case Route.formValue "query" request of
        Nothing ->
            Action.json (Encode.object [ ( "ok", Encode.bool False ) ])

        Just value ->
            Action.json (Encode.object [ ( "query", Encode.string value ) ])
Example

Background task

A Loader can enqueue fire-and-forget work. The Worker adapter decides whether that uses waitUntil or a queue.

Loader.enqueue
    { task = "send-welcome"
    , payload = Encode.string email
    }