Build once.Run at the edge.

elm-ssr ships as a Cloudflare Worker. The Elm route modules stay framework-level; the Worker entry wires assets, generated Elm, effects, sessions, queues, Postgres, and streaming endpoints.

Shortest path
bun add elm-ssr
bun elm-ssr build
wrangler dev
wrangler deploy
Worker setup

Keep Cloudflare concerns in the Worker.

The generated Elm module handles routing. The Worker entry decides which effect runner, session store, queue producer, or SSE endpoint belongs to the deployed environment.

export default createWorkerApp({
  elmModule,
  stylesheet,
  routes,
  islands,
  islandsBundle,
  createFlags,
  effects
});
wrangler.jsonc

Use Wrangler config as the deployment contract: Worker entry, compatibility date, assets, observability, and bindings.

{
  "name": "elm-ssr-site",
  "main": "app/worker.ts",
  "compatibility_date": "2026-06-01",
  "compatibility_flags": ["nodejs_compat_v2"],
  "assets": { "directory": "./public" },
  "observability": { "enabled": true }
}
Postgres

Postgres through Hyperdrive.

Your Elm route stays small: it calls Loader.query, Loader.queryOne, or Loader.execute. The Worker owns the Postgres connection through Hyperdrive and adapts elm-ssr SQL effects to your database client.

  • Create Postgres in your provider: Neon, Supabase, RDS, Timescale, CockroachDB, or another compatible host.
  • Create a Hyperdrive configuration from that database connection string.
  • Bind it as HYPERDRIVE, then read env.HYPERDRIVE.connectionString in the Worker.
Example

Adapt elm-ssr SQL effects to Postgres.

The shape is intentionally boring: create a Postgres client from the binding, run the SQL effect, return rows plus rowCount, and close the client.

import { inMemoryEffects } from "elm-ssr/effects";
import { postgresSql } from "elm-ssr/backends";
import { Client } from "pg";

const effects = async (effect, ctx) =>
  inMemoryEffects({
    sql: postgresSql({
      run: async (sql, params) => {
        const client = new Client({
          connectionString: ctx.env.HYPERDRIVE.connectionString
        });
        await client.connect();
        try {
          const result = await client.query(sql, params);
          return { rows: result.rows, rowCount: result.rowCount ?? 0 };
        } finally {
          await client.end();
        }
      }
    })
  })(effect, ctx);
npx wrangler hyperdrive create app-db \
  --connection-string="postgres://USER:PASS@HOST:5432/DB"

"hyperdrive": [
  { "binding": "HYPERDRIVE", "id": "<hyperdrive-id>" }
]
Session management

Store session data server-side.

Use cacheStore with a durable cache backend in production. Keep SESSION_SECRET in Cloudflare secrets, not in Elm flags.

Cloudflare Worker
import { cacheStore } from "elm-ssr/sessions";
import { redisCache } from "elm-ssr/backends";

createWorkerApp({
  ...app,
  sessions: {
    secret: env.SESSION_SECRET,
    store: cacheStore(redisCache(redis))
  },
  csrf: true
});
Elm route
currentUser : Loader (Maybe User)
currentUser =
    Loader.session userDecoder

csrf : Loader (Maybe String)
csrf =
    Loader.csrfToken
waitUntil

Fast background tasks.

withTasks runs named Loader.enqueue handlers after the response through Worker waitUntil.

const effects = withTasks(baseEffects, {
  warmCache: async (payload) => warmCache(payload)
});
Queues

Durable background delivery.

withQueueProducer sends tasks to Cloudflare Queues. The consumer uses createQueueConsumer with the same handler names.

const effects = withQueueProducer(baseEffects, {
  queueBinding: "JOBS"
});
Server-Sent Events

Use SSE for one-way live updates.

elm-ssr includes elm-ssr/sse. It returns a streaming text/event-stream response backed by the Worker Streams API. Use it for progress, notifications, and live status feeds. createNamedSseStream adds event: stamps and auto-incrementing id: fields.

import { createSseStream } from "elm-ssr/sse";

if (url.pathname === "/events") {
  return createSseStream(request, async (send, signal) => {
    while (!signal.aborted) {
      send(JSON.stringify({ time: Date.now() }));
      await new Promise((r) => setTimeout(r, 1000));
    }
  });
}
Island subscriber

Subscribe from the island, not the page.

The server-rendered page should stay inert. Put EventSource state in an island and decode the messages there.

import ElmSsr.Island.Sse as Sse

init flags =
    ( model, Sse.open "/events" )

subscriptions _ =
    Sse.events GotEvent

update msg model =
    case msg of
        GotEvent event ->
            case Sse.match "/events" tickDecoder event of
                Just (Ok tick) ->
                    ( { model | tick = tick }, Cmd.none )

                _ ->
                    ( model, Cmd.none )
Background work

Choose waitUntil, queues, or jobs.

Loader.enqueue can use withTasks for short post-response work or withQueueProducer for durable queue delivery. Loader.startJob plus withJobs gives a job id and status polling.

Release checklist
Run bun elm-ssr build before deploy so routes, islands, and synced Elm modules are regenerated.
Use wrangler deploy; do not deploy generated files without rebuilding.
Use D1 through cloudflareEffects or Postgres through Hyperdrive plus postgresSql.
Use SSE only for live client updates; normal page data belongs in Loaders.
Use queues for durable work that must survive beyond one request.
Keep secrets in Cloudflare bindings or secrets, not Elm flags or SSR HTML.