My personal website — skyizwhite.dev
A server-rendered site built in Common Lisp, sourcing content from a headless CMS and serving HTML with Next.js-style caching semantics behind a CDN.
| Layer | Choice |
|---|---|
| Language | Common Lisp (SBCL) |
| Dependency manager | qlot |
| Web stack | Clack / Lack |
| HTTP server | Woo (production), Hunchentoot (development) |
| Routing framework | jingle + ningle-fbr (file-based routing) |
| Server actions | ningle-actions — partial-update endpoints in an isolated /actions namespace |
| View / templating | hsx — JSX-like HTML as Lisp s-expressions |
| Content | microCMS via microcms-lisp-sdk |
| Caching | function-cache (in-memory) + HTTP Cache-Control |
| Styling | Tailwind CSS v4 (standalone binary) |
| Interactivity | nomini (reactivity + fragment fetch/swap, self-hosted) |
| CDN | Cloudflare |
| Task runner | just |
| Deployment | Coolify (Docker) |
flowchart TD
Client([Browser])
CDN[Cloudflare CDN]
Woo[Woo]
MW["Lack middleware"]
Woo --> MW
Page["#42;page-app#42;<br/>/"]
Api["#42;api-app#42;<br/>/api"]
Actions["#42;actions-app#42;<br/>/actions"]
Doc["HTML pages"]
Revalidate["webhook handler"]
Def["HTML fragments"]
Cache["function-cache"]
Gap[" "]
CMS[(microCMS)]
Client --> CDN --> Woo
MW --> Page & Api & Actions
Page --> Doc
Api --> Revalidate
Actions --> Def
Doc --> Cache
Revalidate -. clears .-> Cache
Def --> CMS
Cache --> CMS
Def ~~~ Gap ~~~ CMS
classDef edgeStyle fill:#fef3c7,stroke:#d97706,color:#000
classDef serverStyle fill:#dbeafe,stroke:#2563eb,color:#000
classDef appStyle fill:#ede9fe,stroke:#7c3aed,color:#000
classDef dataStyle fill:#dcfce7,stroke:#16a34a,color:#000
class CDN edgeStyle
class Woo,MW serverStyle
class Page,Actions,Api appStyle
class Cache,CMS dataStyle
style Gap fill:none,stroke:none
- Package-inferred system. Each file is its own package (
:class :package-inferred-system); dependencies are resolved fromimport-fromclauses. - File-based routing.
ningle-fbrmaps files undersrc/pages/to HTML routes andsrc/api/to JSON routes. A file exporting@get/@head/@postbecomes a handler;<blog-id>.lispis a dynamic segment. - Three sub-apps.
*page-app*wraps every result in~documentand renders it to an HTML string;*actions-app*(fromningle-actions, plugged into the*page-app*middleware chain by*actions-middleware*and mounted under/actions) renders results as bare HTML fragments for nomini swaps;*api-app*serializes results to JSON and is mounted under/api. - nomini server actions.
ningle-actionskeeps fragment-update endpoints out of the meaningful page URL space by giving them their own isolated/actionsnamespace.defactiondoes two things at once: it registers an HTTP handler under an opaque, auto-generated/actions/<id>URL, and it defines a function of the same name that returns that URL (keyword arguments become URL-encoded query-string params, e.g.(get-likes :blog-id blog-id)). A page embeds that function's return value in a nomininm-bindfetch call ($get/$fetch) instead of a URL literal, so the action URL never appears as a string anywhere and the handler and its view can't drift out of sync. Fragment responses carry anid(and optionalnm-swapstrategy) that nomini matches against the live DOM to swap in place; the guard macrowith-nm-request(insrc/helper.lisp) rejects requests lacking thenm-requestheader. The blog like button (src/components/like-button.lisp, wired up insrc/pages/blog/<blog-id>.lisp) uses this: the pill is lazily loaded via anIntersectionObserver, aPATCHrecords the like to the microCMSlikesfield, and the response swaps in the liked state with a "Thank you!" toast (CSS transitions driven by a nomininm-dataflag). - One like per visitor. Liked post ids are stored in a
liked_blogscookie (src/lib/liked-posts.lispover the genericsrc/lib/cookie.lisp). When lazily loaded the button is rendered in its disabled "already liked" state for returning visitors; thePATCHonly increments for a first-time like (and returns409otherwise), then records the id in the cookie. These per-visitor fragments are servedCache-Control: private, no-storeso the CDN never shares them. - CMS-backed content.
src/lib/cms.lispfetchesabout,works, andblogcontent from microCMS. Calls are memoized withfunction-cache. - Next.js-style cache control.
set-cache(insrc/helper.lisp) setsCache-Controlper page using one of three strategies::ssr— always revalidate (max-age=0, must-revalidate):isr— incremental static regeneration (s-maxage=60, stale-while-revalidate):sg— static generation (s-maxage=1yr)- In dev mode all responses are
no-store.
- On-demand revalidation. A microCMS webhook hits
POST /api/revalidate(auth viaX-MICROCMS-WEBHOOK-KEY), which clears the relevant function-cache entries. On container start,entrypoint.shpurges the Cloudflare cache once the server is ready.
src/
├── main.lisp # start / stop / reload (REPL entry points)
├── app.lisp # app composition, middleware, sub-app mounting
├── document.lisp # top-level HTML shell
├── helper.lisp # shared request/response helpers
├── components/ # reusable hsx components
├── lib/ # shared utility modules
├── pages/ # file-based HTML routes (+ defaction handlers, e.g. blog likes)
└── api/ # file-based JSON routes
assets/ # styles, images, static files
just install # download the Tailwind binary + install qlot dependencies
just watch # rebuild CSS on change
just build # build the production CSS bundle
just lem # open the Lem editor with the CSS watcher runningStart the server from a Lisp REPL:
(ql:quickload :website)
(website:start) ; serves on http://localhost:3000
(website:reload) ; reload code and restart
(website:stop)Configure via .env (see .env.example):
WEBSITE_ENV # "dev" enables dev-mode caching/error pages
WEBSITE_URL # canonical base URL
MICROCMS_SERVICE_DOMAIN
MICROCMS_API_KEY
MICROCMS_WEBHOOK_KEY # validates the revalidate webhook
CLOUDFLARE_ZONE_ID # optional, for cache purge on deploy
CLOUDFLARE_API_KEY
Deployed on Coolify, which builds the Dockerfile and runs the container. The Dockerfile builds the system with qlot, minifies the Tailwind CSS, and runs entrypoint.sh, which serves the app with Woo on port 3000 and purges the Cloudflare cache after the rolling update completes.