Files
hemx/docs/tutorial-saas.md
T
slhx agent bf7560b7a8 docs(runtime): align docs with hashed runtime asset
Replace fixed /hemx.js wording with helper-provided runtime asset language so docs match hemx-axum runtime_js_path deployment boundaries.

req: axum_integration/005

req: examples/001
2026-06-11 22:38:37 +02:00

8.4 KiB

Tutorial: production-shaped SaaS app

This walkthrough explains the canonical v1 tutorial path in examples/saas. It is intentionally provider-light: the app proves auth/session shape, CSRF-safe mutation, local persistence, generated swaps, page/push shape, plain CSS, and one explicit island without moving SQL, auth, flags, deploy, or observability providers into hemx core. req: examples/001 req: laws/002

Run it:

cargo run -p hemx-saas-example
cargo test -p hemx-saas-example

What you are building

The tutorial app is a small project dashboard:

  • a full page shell rendered by Rust and hemplate
  • a Dashboard template with a project creation form
  • typed domain inputs: CsrfToken, ProjectName, and ProjectId
  • an app-owned LocalProjectStore persistence adapter
  • an auth/session-shaped AppContext
  • a CSRF-checked mutation handler
  • generated form, summary, flash, keyed row, page-panel, and live-status effects
  • an SSE/polling-shaped live status endpoint
  • plain CSS and one explicit metrics island script

The important point is not the project domain; it is the boundary: templates declare the UI surface, Rust owns domain state, handlers return generated UI commands, and the browser runtime only applies checked effects. req: canonical_authoring/001 req: modes/001

Files to read first

  • examples/saas/templates/dashboard.heml — the UI contract
  • examples/saas/src/lib.rs — domain types, app context, handlers, and tests
  • examples/saas/src/main.rs — Axum route wiring and runtime/static assets
  • examples/saas/templates/app.css — plain CSS
  • examples/saas/templates/metrics.js — explicit leaf-island JavaScript
  • examples/saas/README.md — scope and provider boundaries

1. Declare the surface in hemplate

The dashboard template names only facts that hemx can check and generate:

<section data-hemx-root="dashboard" data-hemx-sse="/events">
  <form data-hemx-handle="create_project" data-hemx-form="new_project">
    <input type="hidden" name="csrf" +value="self.csrf">
    <input name="name" required="required">
    <p data-hemx-error-for="name"></p>
  </form>

  <p data-hemx-slot="flash">{+ self.flash +}</p>
  <p data-hemx-slot="summary">{+ self.summary +}</p>

  <ul data-hemx-slot="project_row">
    <template h-for="row in &self.rows" h-key="row.id">
      {+ row +}
    </template>
  </ul>
</section>

There are no selectors, numeric ids, raw targets, or runtime opcodes in the template. The h-key gives the keyed row target enough information for generated append/replace/remove helpers. {+ row +} renders the child hemplate partial; {+= html =+} is only for already-trusted HTML. req: canonical_authoring/002 req: list/001

2. Keep domain types ordinary

The form type is Rust domain code, not a generated DTO:

#[derive(Clone, Debug)]
#[hemx::form("new_project")]
pub struct NewProject {
    csrf: CsrfToken,
    name: ProjectName,
}

ProjectName trims submitted input via FromStr; CsrfToken is a typed value; ProjectId implements Display for stable keyed row ids. The generated form contract checks that the Rust shape matches the HTML controls. req: form/001 req: codegen/004

3. Put platform boundaries in app state

AppContext carries the authenticated session and persistence adapter:

#[derive(Clone)]
pub struct AppContext {
    session: Session,
    store: LocalProjectStore,
}

The local store is deliberately small and testable. Production providers are recipes, not core dependencies:

  • SQLx: docs/recipes/sqlx-persistence.md
  • auth/session and CSRF middleware: docs/recipes/auth-session-csrf.md
  • observability, feature flags, and killswitches: docs/recipes/observability-flags.md
  • deploy/runtime compatibility: docs/recipes/deploy-versioning.md

This keeps hemx focused on the UI contract while the app owns platform choices. req: auth/001 req: laws/004

4. Write one boring handler

The create handler checks session/CSRF, validates input, persists a record, and returns generated UI commands:

#[hemx::handler]
async fn create_project(
    State(ctx): State<AppContext>,
    Form(form): Form<NewProject>,
) -> Result<impl IntoEffect, AppError> {
    if form.csrf != ctx.session.csrf {
        return Err(AppError::CsrfRejected);
    }
    if form.name.as_str().is_empty() {
        return Err(AppError::Validation("Project name required"));
    }

    let project = ctx.store.insert(form.name, &ctx.session)?;
    let total = ctx.projects().len();

    Ok((
        dashboard::project_row.append(ProjectRow::from(project)),
        dashboard::summary.set(project_summary(total)),
        dashboard::new_project.clear(),
        dashboard::flash.set("Project created"),
        dashboard::live_status.set(format!("{total} projects persisted locally")),
    ))
}

The handler does not choose targets with CSS selectors, construct raw effects, parse raw forms, or call the runtime. It returns intent through generated helpers and tuple composition. req: canonical_authoring/003 req: dx/007

5. Map failures explicitly

Expected validation and platform failures cross one app error boundary:

impl IntoHandlerFailure for AppError {
    fn into_handler_failure(self, context: HandlerErrorContext) -> HandlerFailure {
        match self {
            AppError::Validation(message) => HandlerFailure::effects(
                (
                    dashboard::new_project.error("name", message),
                    dashboard::new_project.focus("name"),
                ),
                context,
            ),
            other => HandlerFailure::effects(dashboard::flash.set(other.message()), context),
        }
    }
}

That keeps user mistakes visible in the generated form error target and keeps infrastructure failures out of the normal success path. req: failure/004

6. Add page and push shape without a frontend app

The settings handler swaps a generated page panel and pushes history:

(
    dashboard::page_panel.put(&SettingsPage { message: "..." }),
    dashboard::nav.set("Settings"),
    hemx::push("/settings"),
)

The live-status endpoint sends generated effect batches over SSE/polling-shaped transport. Routing, auth, and connection policy stay in Axum/app code; hemx does not become a router or transport framework. req: page_swap/002 req: push/003

7. Keep CSS and islands explicit

Appearance is plain CSS in templates/app.css. The metrics widget is an opaque leaf island declared with data-hemx-island="metrics" and implemented by templates/metrics.js. The island may inspect its own leaf DOM; ordinary forms, lists, page swaps, and live status do not require handwritten JavaScript. req: canonical_authoring/007 req: dx/008

8. Test at the product boundary

cargo test -p hemx-saas-example proves the tutorial shape:

  • the page contains the root, generated form, CSRF field, SSE marker, island, CSS, and island asset
  • stale CSRF does not mutate the store and maps to generated UI
  • validation maps to a generated form error
  • valid mutation persists locally and returns generated keyed row, summary, form, and live-status effects
  • page swap and push shape use generated targets

These tests are intentionally app-level. They prove behavior without browser provider setup or external database side effects. req: test/001 req: examples/001

9. Productionize by swapping adapters, not changing hemx

To move from the local tutorial skeleton to production:

  1. Replace LocalProjectStore with a SQLx adapter from docs/recipes/sqlx-persistence.md.
  2. Replace the demo Session with an Axum/Tower extractor and CSRF service from docs/recipes/auth-session-csrf.md.
  3. Wrap routes/handlers with app-owned metrics, flags, and killswitches from docs/recipes/observability-flags.md.
  4. Add optional PWA/offline behavior only through the adapter boundary in docs/recipes/pwa-offline.md.
  5. Deploy server, generated output, and the helper-provided runtime asset as one release unit following docs/recipes/deploy-versioning.md.
  6. Follow docs/versioning.md for semver and upgrade notes.
  7. Use docs/diagnostics.md when a template/build/derive/runtime mistake fails the app.

The handler and template model should stay recognizable throughout those swaps. If productionizing requires raw ids, selector retargeting, manual registries, or client app state, treat that as a design smell and either add a named advanced escape hatch or keep the provider integration outside the beginner path. req: public_api/005 req: runtime/003