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.
How elm-ssr fits together: Elm route modules, the generated router, the Worker runtime, and interactive islands.
Runtime pulse
A route returns a Document. If that document contains no island marker, elm-ssr does not add the island script.
The elm-ssr build scans Routes/, maps file paths to URLs, and generates the Elm router used by the Worker.
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 Worker receives the request, calls the generated Elm app, serves the stylesheet and island runtime, and exposes the route catalog.
The architecture is easier to read when every concept has room: a short explanation beside a readable code sample.
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"
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
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")
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
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
});
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
)
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 ]
}
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 ) ])
Cookies attach to an Action result, including redirects. Session cookies are preconfigured for HttpOnly, Secure, SameSite=Lax, and Path=/.
Action.redirect "/dashboard"
|> Action.setCookie
(Action.sessionCookie "session" sessionId)
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
}