Skip to main content
All Examples

Server-Side Rendering

A server renders each request into HTML using Flags read from a cookie, and the client hydrates with the exact values the server used. Reload the page and your latest count arrives already in the markup, before any JavaScript runs.

Server Rendering
Hydration
Flags
Server-Side Rendering renders each page on a server at request time, so a static preview cannot demonstrate it. Launch the playground to see the server round-trip live, or run the example locally.
import { Effect, Schema } from 'effect'
import { Command, Runtime, type Update } from 'foldkit'
import { type Document, type Html, type HtmlBuilder } from 'foldkit/html'
import { defineMessageUnion } from 'foldkit/message'
import { modifyFields } from 'foldkit/struct'

import { Button } from '@foldkit/ui'

import { COUNT_COOKIE } from './cookie'

// MODEL

export const Model = Schema.Struct({
  count: Schema.Number,
  renderedAt: Schema.String,
  renderedOn: Schema.Literals(['Server', 'Client']),
})
export type Model = typeof Model.Type

// FLAGS

export const Flags = Schema.Struct({
  initialCount: Schema.Number,
  renderedAt: Schema.String,
  renderedOn: Schema.Literals(['Server', 'Client']),
})
export type Flags = typeof Flags.Type

// MESSAGE

export const Message = defineMessageUnion({
  ClickedDecrement: {},
  ClickedIncrement: {},
  CompletedPersistCount: {},
})

export type Message = typeof Message.Type

// UPDATE

export const update = (model: Model, message: Message) =>
  Message.match<Update.Return<Model, Message>>(message, {
    ClickedDecrement: () => {
      const nextCount = model.count - 1
      return {
        model: modifyFields(model, { count: () => nextCount }),
        commands: [PersistCount({ count: nextCount })],
      }
    },
    ClickedIncrement: () => {
      const nextCount = model.count + 1
      return {
        model: modifyFields(model, { count: () => nextCount }),
        commands: [PersistCount({ count: nextCount })],
      }
    },
    CompletedPersistCount: () => ({ model }),
  })

// COMMAND

const COUNT_COOKIE_MAX_AGE_SECONDS = 31536000

export const PersistCount = Command.define('PersistCount', {
  args: { count: Schema.Number },
  messages: [Message.CompletedPersistCount],
  execute: ({ count }) =>
    Effect.try(() => {
      document.cookie = `${COUNT_COOKIE}=${count}; path=/; max-age=${COUNT_COOKIE_MAX_AGE_SECONDS}`
    }).pipe(
      Effect.map(() => Message.CompletedPersistCount()),
      Effect.catch(() => Effect.succeed(Message.CompletedPersistCount())),
    ),
})

// INIT

export const init: Runtime.ApplicationInit<Model, Message, Flags> = flags => ({
  model: {
    count: flags.initialCount,
    renderedAt: flags.renderedAt,
    renderedOn: flags.renderedOn,
  },
})

// VIEW

export const view = (model: Model, h: HtmlBuilder<Message>): Document => ({
  title: `Count ${model.count}`,
  body: h.div(
    [
      h.Class(
        'min-h-screen bg-white flex flex-col items-center justify-center gap-6 p-6',
      ),
    ],
    [
      h.h1(
        [h.Class('text-2xl font-semibold text-gray-800')],
        ['Server-rendered counter'],
      ),
      h.p(
        [h.Id('count'), h.Class('text-6xl font-bold text-gray-800')],
        [model.count.toString()],
      ),
      h.div(
        [h.Class('flex flex-wrap justify-center gap-4')],
        [
          Button.view(
            {
              onClick: Message.ClickedDecrement(),
              toView: attributes =>
                h.button([...attributes.button, h.Class(buttonStyle)], ['-']),
            },
            h,
          ),
          Button.view(
            {
              onClick: Message.ClickedIncrement(),
              toView: attributes =>
                h.button([...attributes.button, h.Class(buttonStyle)], ['+']),
            },
            h,
          ),
        ],
      ),
      h.p(
        [h.Id('provenance'), h.Class('text-sm text-gray-500')],
        [`Rendered on the ${model.renderedOn} at ${model.renderedAt}`],
      ),
      h.p(
        [h.Class('text-sm text-gray-500 max-w-md text-center')],
        [
          'The count persists in a cookie. Reload the page and the server ' +
            'renders your latest count into the HTML before any JavaScript runs.',
        ],
      ),
      parseEquivalenceView(h),
    ],
  ),
})

// NOTE: elements whose served markup and whose freshly built DOM are easy to
// get subtly different. A browser gives a later `selected` option ownership
// while the DOM `value` setter takes the first match, and it drops one newline
// after a <pre> or <textarea> start tag that assigning `innerHTML` would keep.
// The e2e suite loads this page once with scripting off and once hydrated, and
// requires the two readings to agree.
const parseEquivalenceView = (h: HtmlBuilder<Message>): Html =>
  h.div(
    [h.Id('parse-equivalence'), h.Class('hidden')],
    [
      h.select(
        [h.Id('equivalence-select'), h.Value('a')],
        [
          h.option([h.Value('a')], ['A']),
          h.option([h.Value('a'), h.Selected(true)], ['B']),
        ],
      ),
      h.pre([h.Id('equivalence-pre'), h.InnerHTML('\nleading')]),
      h.textarea([h.Id('equivalence-textarea'), h.Value('\nleading')]),
    ],
  )

// STYLE

const buttonStyle = 'bg-black text-white hover:bg-gray-700 px-4 py-2 transition'