SSR / Pages that work without JavaScript

Sixty seconds: one form that works both ways

A cart with one button. The Message carries the name, not the domain and its price: a form posted without scripts can carry anything, so update decides what the name is worth.

ts
import { Array, Schema } from 'effect'
import type { HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'
import { Resume, SSR } from 'foldkit-ssr'
import { Projection, Surface } from 'foldkit-surface'

const Model = Schema.Struct({ cart: Schema.Array(Schema.String) })
type Model = typeof Model.Type

const Message = defineMessageUnion({
  Added: { name: Schema.String.check(Schema.isMaxLength(128)) },
})
type Message = typeof Message.Type

const initial: Model = { cart: [] }
const App = Surface.application({ Model, Message, initial, update: model => ({ model }) })

const update = (model: Model, message: Message) =>
  Message.match(message, {
    Added: ({ name }) => ({
      model: Array.contains(model.cart, name)
        ? model
        : modifyFields(model, { cart: cart => Array.append(cart, name) }),
    }),
  })

const view = (model: Model, h: HtmlBuilder<Message>) => {
  const rh = Resume.builder(h)
  return {
    title: 'Cart',
    body: rh.main(
      [],
      [
        rh.p([], [`${model.cart.length} in the cart`]),
        rh.form(
          [rh.OnSubmit(Message.Added({ name: 'acme.com' }))],
          [rh.button([rh.Type('submit')], ['Add acme.com'])],
        ),
      ],
    ),
  }
}

/** The Messages a posted form may carry: what the cart's controls send. */
const CartControls = App.surface('CartControls', {
  model: ({ model }) => ({ cart: model.cart }),
  messages: [Message.Added],
})

const plan = SSR.plan(App, {
  id: 'cart',
  state: Projection.pick(App.model.cart),
  surfaces: [Surface.at(CartControls, undefined)],
  fallback: 'server',
})

export const { renderPage } = SSR.entry(
  { Model, init: () => ({ model: initial }), update, view, container: null },
  plan,
  { buildId: 'build-1', origins: ['https://shop.example'] },
)

What each part does, and does not do:

  • rh.form with rh.OnSubmit(Message) is an ordinary Foldkit form while scripts run: the submit is the Message, update answers it, and the browser sends nothing. Because the plan has fallback: 'server', the server also writes the form method="post" with the Message encoded in a hidden field. SSR.hydrate takes both out as the page boots, so the browser's first render matches the page it adopts.
  • fallback: 'server' makes SSR.entry take a POST. SSR.handle decodes the posted Message through the plan's Message Schema (a name over 128 characters is a 400), refuses one no active Surface lists, rebuilds the Model as a GET would (init, then the plan's boot), runs update and every Command after it, and renders the page.
  • origins is required with a fallback: a form cannot carry a CSRF header, so a post whose Origin is not listed is a 403 before its body is read.
  • It does not keep the cart. Each post starts from init, so a second add finds an empty cart. Keeping state between posts is the application's (below).