Frontend

Integration

A WebFluid app declares what kind of frontend it serves through a single config value: APP_FRONTEND. There are three flavours — plain server-rendered, htmx-enhanced, or a full Vite-driven SPA. We'll start with the small end and only serve the main app, then get fancy with multiple frontends once we reach Additives.

The frontend config

APP_FRONTEND is just a dictionary in your config class. Its type decides everything else:

  • none — pure SSR, no client tooling.
  • htmx — SSR plus htmx (and optionally Alpine) injected for you.
  • vite — a real Vite app served and hot-reloaded by the framework.

The easy win: htmx

The lightest frontend needs no Node at all. Declare the htmx type and the framework hands your templates a frontend() helper that injects the right scripts (and the compiled Tailwind link):

fluid/config.py python
from webfluid.core.config import register_config


@register_config(10)
class MyConfig:
    SESSION_COOKIE_SECURE = True

    APP_FRONTEND = {
        "type": "htmx",
        "alpine": True
    }
fluid/templates/index.html html
{% extends "fluid_base.html" %}

{% block head %}
    {{ frontend() if frontend else "" }}
{% endblock %}

{% block content %}
    <button hx-get="/health" hx-target="#out">{{ _('Ping') }}</button>
    <pre id="out"></pre>
{% endblock %}

That is genuinely the whole story for an htmx app. The frontend() call resolves to the htmx (and Alpine) script tags, and because we are extending fluid_base.html, everything else is already in place.

 

The frontend() helper looks for a raw stylesheet in that surface's static/csstailwind_raw.css with themes enabled, tailwind_no_themes.css without. If it finds neither while WF_TAILWIND is on, it still writes a stylesheet link, but with an empty href, and your browser fetches the page a second time trying to parse it as CSS. Keep a raw stylesheet next to every surface that calls frontend(), or switch WF_TAILWIND off for apps that do not style anything.

Going SPA: the fluid/frontend workspace

For a richer client you switch to the vite type. The main app's frontend lives in one place the framework knows to look for: a fluid/frontend directory, set up as an npm workspace. Three pieces make that work:

  1. a root package.json that declares the workspace,
  2. a root vite.config.js that orchestrates it (the framework writes this one for you),
  3. the actual Vite app inside fluid/frontend.

For now we only have the main app, so the workspace list is tiny — just our one frontend. (We'll add additives/*/frontend to it later.)

package.json json
{
  "name": "myapp",
  "private": true,
  "workspaces": [
    "fluid/frontend"
  ],
  "devDependencies": {
    "vite": "^8.0.0"
  }
}
 

You do not hand-write the root vite.config.js. The framework ships an orchestrator that discovers every workspace, merges their configs and adds the dev plugin that resolves your assets. We'll leave that machine in its box here and only touch the small per-app config.

Inside fluid/frontend you have an ordinary Vite project. The one thing the framework needs from its config is the right base: the built assets are served under /frontend/, while in development everything is proxied through /vite-dev/:

fluid/frontend/vite.config.js js
import { defineConfig } from 'vite'

export default defineConfig(({ command }) => ({
  base: command === 'build' ? '/frontend/' : '/vite-dev/',
}))

Tell the config which app this is and declare your APP_FRONTEND:

fluid/config.py python
@register_config(10)
class MyConfig:
    APP_FRONTEND = {
        "type": "vite",
        "framework": "react",
        "typescript": False,
        "register_index": True
    }

How it gets served

When the type is vite, the framework takes over the / route and serves your Vite app's index for you — you don't register a home handler at all. That is the default (register_index: true); set register_index: false in your APP_FRONTEND if you would rather own the / route yourself. What happens behind that route depends on the mode:

  • Development (wf run app -d): the bundled Node starts the Vite dev server, and the framework proxies it under /vite-dev/. You get hot module replacement for free, rewritten into your page on the fly.
  • Production (no -d): the workspaces are built once on startup and the resulting dist is mounted as static files under /frontend.

Deciding what happens on a production boot

That build step is where the two remaining feature switches earn their keep. On a production start the framework runs, in order:

  • WF_CHECK_FRONTENDnpm run check --workspaces. The scaffolder wires this script up for TypeScript templates, so a type error stops the boot instead of shipping. Off by default.
  • WF_BUILD_FRONTENDnpm run build --workspaces, and a failing build aborts startup with the compiler output. On by default.

Both are worth turning off in an image that already built its assets at container build time — there is no reason to compile the same bundle again on every restart, and a container without a Node toolchain cannot anyway. The dist folders are mounted either way.

 

The register_index flag decides whether the framework takes the / route for you. It defaults to true, and wf create project asks about it when you pick the Vite type. Set it to false and the Vite index is still served — you just have to wire the route yourself, which is what you want as soon as the SPA is not the front page.

 

Setting all of this up by hand is fiddly, and that is by design: it is exactly the kind of boilerplate the CLI exists to remove. When we reach wf create project you'll see this whole workspace generated in one command. Here it is enough to understand the moving parts.

Continue reading

From here you can continue straight with Template resolution.