Worker handles framework assets first.
The request handler serves /styles.css, /__elm-ssr/islands.js, /__elm-ssr/islands-bundle.js, /health, /api/health, /api/routes, and /api/render before falling through to page rendering.
The Worker receives the request, builds flags for Elm, runs the generated app, performs any described effects, then returns HTML, JSON, a redirect, or an error response.
createWorkerApp
renderApp
cloudflareEffects
Loader.requireUser
Action.requireUser
Loader.softExecute
Loader.transaction
Loader.custom
withJobs
createSseStream
createNamedSseStream
withTasks
runMigrations
sessionMiddleware
The request handler serves /styles.css, /__elm-ssr/islands.js, /__elm-ssr/islands-bundle.js, /health, /api/health, /api/routes, and /api/render before falling through to page rendering.
GET and HEAD render through the route's page : Request -> Loader (Document msg). Unsupported methods return 405; POST-style work belongs in action.
A Loader or Action can pause on effects like fetch, cache, SQL, env, cookie, queue, or session. The Worker runs the effect and resumes Elm with decoded data.
The runtime returns a redirect, JSON, or a serialized Document. Action cookies are appended as Set-Cookie headers.
The app supplies the compiled Elm module, stylesheet text, route catalog, island metadata, an optional island bundle, a flag factory, and optional effects.
export const worker = createWorkerApp({
elmModule: Main,
stylesheet,
routes,
islands,
islandsBundle,
createFlags,
effects: cloudflareEffects(env)
});
The runtime installs error, request id, timing, logging, and HEAD middleware. Sessions add session middleware and wrap effects; CSRF requires sessions.
createWorkerApp({
...app,
sessions: sessionOptions,
csrf: true
})
Cache helpers are backend-neutral; the selected TS-side effect runner decides whether the backing store is local, KV, Redis, or another adapter.
Loader.cacheGet
{ key = "home:data"
, decoder = dataDecoder
}
query, queryOne, and execute use SQL plus JSON params. Actions can reuse them through Action.fromLoader.
Loader.queryOne
{ sql = "select * from posts where slug = ?"
, params = [ Encode.string slug ]
, decoder = postDecoder
}
enqueue describes fire-and-forget work. The Worker task adapter decides how to run it, including waitUntil or queues.
Loader.enqueue
{ task = "send-welcome"
, payload = Encode.string email
}
Actions can attach cookies to redirects, JSON responses, failures, or documents.
Action.redirect "/dashboard"
|> Action.setCookie
(Action.sessionCookie "session" sid)
softExecute and softQueryOne return Ok / Err ConstraintError instead of failing the Loader — handle UNIQUE violations as a normal business case.
Loader.softExecute
{ sql = "insert into subscribers(email) values (?)"
, params = [ Encode.string email ]
}
-- Ok { rowsAffected } | Err (ConstraintError msg)
Loader.transaction wraps multi-step writes. If any step fails, everything rolls back. Requires sqlTransaction on the adapter.
Loader.transaction
(Loader.execute
{ sql = "update orders set status = 'placed'"
++ " where id = ?"
, params = [ Encode.string orderId ]
}
)
Use Loader.query, Loader.queryOne, and Loader.execute in routes. Use cloudflareEffects for D1, or postgresSql with your Postgres client for Postgres.
import { inMemoryEffects } from "elm-ssr/effects";
import { postgresSql } from "elm-ssr/backends";
const baseEffects = inMemoryEffects({
sql: postgresSql({
run: (sql, params) =>
pool.query(sql, params)
.then((r) => ({ rows: r.rows, rowCount: r.rowCount ?? 0 }))
})
});
Elm routeLoader.queryOne
{ sql = "select * from users where id = ?"
, params = [ Encode.string userId ]
, decoder = userDecoder
}
Loader.custom is the escape hatch for one typed Worker operation. Use it for parallel queries, AI calls, search, or a backend API that should not leak into route code.
Loader.custom
{ kind = "parallelDashboard"
, payload = Encode.object []
, decoder = dashboardDecoder
}
Loader.startJob returns a job id immediately. Loader.jobStatus decodes queued, running, done, failed, or missing states from the TS-side job store.
Loader.startJob
{ kind = "generateReport"
, payload = Encode.object [ ( "month", Encode.string month ) ]
}
Loader.jobStatus { jobId = id, decoder = reportDecoder }
The Worker can wrap the base effect runner with tasks, queue producers, jobs, caches, sessions, or custom branches. The route module keeps the same Loader API.
const effects = withJobs(
withQueueProducer(baseEffects, { queueBinding: "JOBS" }),
{ store: cacheJobStore(cache), handlers: { generateReport } }
);
withTasks handles Loader.enqueue by scheduling named handlers after the response. This is good for audits, cache warmups, and non-critical notifications.
const effects = withTasks(baseEffects, {
auditEntry: async (payload, ctx) => {
await writeAuditLog(payload);
}
});
withQueueProducer sends Loader.enqueue payloads to a Cloudflare Queue. createQueueConsumer maps each queued task to a handler.
export default {
fetch: worker.fetch,
queue: createQueueConsumer({ auditEntry, warmCache })
};
Mount sessions on createWorkerApp. Then Elm can use Loader.session, Loader.csrfToken, Loader.setSession, and Loader.clearSession.
import { cacheStore } from "elm-ssr/sessions";
createWorkerApp({
...app,
sessions: {
secret: env.SESSION_SECRET,
store: cacheStore(redisCache(redis))
},
csrf: true
});
Elm actionAction.fromLoader (Loader.setSession (encodeUser user))
|> Action.andThen (\_ -> Action.redirect "/dashboard")
Action.fromLoader Loader.clearSession
|> Action.andThen (\_ -> Action.redirect "/")
Loader.requireUser decodes the session cookie into your User type and redirects unauthenticated visitors to /login. Action.requireUser does the same for POST-style actions. A malformed session cookie produces **502**, not a redirect — test this explicitly.
page request =
Loader.requireUser userDecoder request
|> Loader.andThen (\user ->
Loader.map2
(viewProfile user)
(Loader.csrfToken request)
)
Protected actionaction request =
Action.requireUser userDecoder request
|> Action.andThen (\user ->
Action.fromLoader (savePost user.id title)
|> Action.andThen
(\_ -> Action.redirect "/posts")
)
Loader.redirect issues a 302 directly from a Loader without going through an Action. Useful when a page needs a conditional redirect before rendering.
page request =
case Route.query "ref" request of
Nothing ->
Loader.redirect "/home"
Just ref ->
loadWithRef ref
The upstream README frames effects as backend-neutral. Elm describes cacheGet, query, env, fetchJson, enqueue, custom, startJob, or jobStatus; TypeScript adapters decide whether that means KV/D1, Redis/Postgres, SQLite, memory, queues, waitUntil, or a custom service.
import { cloudflareEffects } from "elm-ssr/effects";
import { withTasks } from "elm-ssr/tasks";
const effects = withTasks(
cloudflareEffects({ cacheBinding: "CACHE", dbBinding: "DB" }),
{ sendEmail, warmCache }
);
Local testsconst effects = withTasks(
withCache(
inMemoryEffects({ sql: postgresSql(pg), env }),
redisCache(redis)
),
{ sendEmail, warmCache }
);
The README documents SQL-file migrations with a tracking table. Files apply in alphabetical order, and each migration plus its tracking insert runs transactionally when the adapter supports transactions.
Up
elm-ssr migrate up --dir ./migrations --db ./app.db
Status
elm-ssr migrate status --dir ./migrations --db ./app.db
Down
elm-ssr migrate down --count 1 --dir ./migrations --db ./app.db
Backends
The CLI accepts Postgres URLs, SQLite URLs, plain SQLite paths, or DATABASE_URL.
import { runMigrations } from "elm-ssr/migrations";
const result = await runMigrations(adapter, {
dir: "./migrations"
});
The adapter shape is small enough to wire to Bun SQLite, Postgres clients, or Cloudflare D1.
createSseStream returns a text/event-stream response. ElmSsr.Island.Sse opens an EventSource per URL and feeds messages into an island subscription.
return createSseStream(request, async (send, signal) => {
while (!signal.aborted) {
send(JSON.stringify({ time: Date.now() }));
await new Promise((r) => setTimeout(r, 1000));
}
});
init flags =
( model, Sse.open "/events" )
subscriptions _ =
Sse.events GotEvent
Sse.match "/events" decoder event
Use SSE for live updates, not initial page data. Initial data belongs in the Loader so the HTML is useful before any client code runs.
Island.embed renders an elm-ssr-island element with data-elmssr-island, encoded props, an optional stable id, and fallback children. The browser runtime finds that marker and starts the island's own Elm main.
Fallback
HTML rendered on the server before the island bundle starts.
Flags
Props are encoded as JSON by the server-side embed config.
Stable id
Optional id lets the runtime preserve an island across client navigation.
Normal Elm
The island internals use ordinary Elm browser code.
Island.embed "RuntimePulse"
{ encodeFlags = encodeFlags
, fallback = fallback
, id = Just "runtime-pulse"
}
flags
The island name must match the generated browser bundle key for the island module.