Skip to content

Website Tech Stack

Karthik P edited this page Oct 27, 2026 · 5 revisions

This page lists what the website is built with, how website/ is laid out and configured for GitHub Pages, and how it is built, checked, deployed and kept up to date. Use it when setting up the site, changing its configuration or adding a package.

Decided items come from the decision log on Design System; Proposed items are not approved. Versions were checked on the npm registry on 2026-10-24.

The stack

Piece Version on 2026-10-24 Why
Next.js (App Router, output: 'export') 16.4.3 Builds every page to static HTML, so GitHub Pages can serve it with no server (static exports).
React, React DOM 19.3.1 Required by Next.js 16 and Fumadocs UI.
fumadocs-core 16.17.8 Page tree, search index, llms(), Markdown utilities.
fumadocs-ui 16.17.8 Notebook layout, sidebar, table of contents, search dialog, tabs, callouts, code blocks. Same version as fumadocs-core (an exact peer dependency).
fumadocs-mdx 15.4.10 Compiles content/docs/ and gives each page its TOC and processed Markdown.
Tailwind CSS v4, @tailwindcss/postcss 4.3.3 Fumadocs UI is styled with Tailwind v4; the tokens become @theme variables (Design Tokens).
TypeScript, @types/* 7.0.2 Type checks the site, including the demo's use of the workclock types.
lucide-react 1.61.0 Icons; already a fumadocs-ui dependency (Icons and Illustration).
@playwright/test, @axe-core/playwright exact versions, dev only Accessibility checks on the built site (Accessibility).
next/font, next/og part of Next.js Self-hosted fonts; social cards drawn at build time.
Shiki, Fumadocs static search through Fumadocs Code highlighted at build time; a search index searched in the browser.
workclock built from ../js The demo and the static week strips (below).

Next.js 16 builds with Turbopack by default (Next.js 16) and needs Node.js 20.9 or later; CI uses Node.js 26, like the release workflow.

Folder layout

The layout follows Fumadocs' static Next.js example, examples/next-static (create-fumadocs-app template +next+fuma-docs-mdx+static), with collections defined by the Fumadocs MDX Macro API in lib/source.ts. Every route handler's path ends in a file extension (search.json, content.md, image.png), so trailingSlash does not touch it and GitHub Pages picks the content type from the extension.

website/
├── package.json             exact versions, "private": true
├── package-lock.json        committed; CI runs npm ci
├── .npmrc                   save-exact=true
├── next.config.mjs
├── postcss.config.mjs       { plugins: { '@tailwindcss/postcss': {} } }
├── source.config.ts         global MDX options (Shiki themes)
├── playwright.config.ts
├── tsconfig.json
├── app/
│   ├── layout.tsx           <html>, fonts, Provider, metadataBase
│   ├── global.css           Tailwind, Fumadocs CSS, tokens, overrides
│   ├── not-found.tsx        → out/404.html
│   ├── opengraph-image.png  site card, from Canva
│   ├── opengraph-image.alt.txt
│   ├── icon.svg, apple-icon.png, favicon.ico
│   ├── (home)/
│   │   ├── layout.tsx       Fumadocs HomeLayout
│   │   ├── page.tsx         landing page
│   │   └── playground/page.tsx
│   ├── docs/
│   │   ├── layout.tsx       DocsLayout from fumadocs-ui/layouts/notebook, nav.mode 'top'
│   │   └── [[...slug]]/page.tsx   DocsPage from fumadocs-ui/layouts/notebook/page
│   ├── api/search.json/route.ts           → out/api/search.json
│   ├── llms.txt/route.ts                  → out/llms.txt
│   ├── llms-full.txt/route.ts             → out/llms-full.txt
│   ├── llms.mdx/docs/[[...slug]]/route.ts → out/llms.mdx/docs/<slug>/content.md
│   └── og/docs/[...slug]/route.tsx        → out/og/docs/<slug>/image.png
├── assets/fonts/            InstrumentSans-SemiBold.ttf, OFL.txt (for next/og)
├── components/              provider, search, mdx, page-actions, install-tabs,
│                            feedback-link, site-footer, badge, skip-link,
│                            week-strip/, use-workclock.ts
├── content/docs/            the docs pages and meta.json files
├── lib/
│   ├── source.ts            defineDocs + loader()
│   ├── shared.ts            site URL, route helpers
│   └── layout.shared.tsx    header options for both layouts
├── scripts/copy-markdown.mjs   Proposed post-build step
├── tests/a11y.spec.ts
└── public/                  files served as they are

The example's lib/cn.ts imports the cn package, which is not approved; write the few class-joining calls by hand.

next.config.mjs

import path from 'node:path';
import { createMDX } from 'fumadocs-mdx/next';

const withMDX = createMDX();

/** @type {import('next').NextConfig} */
const config = {
  output: 'export',
  trailingSlash: true,
  images: { unoptimized: true },
  reactStrictMode: true,
  // workclock is linked from ../js, outside the project root.
  turbopack: { root: path.join(import.meta.dirname, '..') },
};

export default withMDX(config);
Setting Why Source
output: 'export' Writes the site to website/out/. Static exports
trailingSlash: true Each page becomes docs/x/index.html, which GitHub Pages serves at /docs/x/. Check that /docs/x also reaches the page after the first deploy. trailingSlash
images: { unoptimized: true } The default image loader needs a server. Image: unoptimized
turbopack.root Turbopack does not resolve files outside the project root; the file:../js link needs the root set to the repository. turbopack: root

Not available in a static export, so not used: rewrites, redirects, custom headers, proxy.ts, Server Actions, ISR, route handlers that read the request (unsupported features).

Absolute URLs

The site is served from the root of https://workclock.karthik.nz/, so it sets no basePath. Links in llms.txt, .md files and social cards are absolute (https://workclock.karthik.nz/...), built from siteUrl in lib/shared.ts.

Set metadataBase: new URL('https://workclock.karthik.nz/') in app/layout.tsx so metadata image URLs are absolute.

Provider

// website/components/provider.tsx
'use client';
import { LucideProvider } from 'lucide-react';
import { RootProvider } from 'fumadocs-ui/provider/next';
import type { ReactNode } from 'react';
import SearchDialog from '@/components/search';

export function Provider({ children }: { children: ReactNode }) {
  return (
    <LucideProvider strokeWidth={1.5}>
      <RootProvider search={{ SearchDialog }} theme={{ hotKey: false }}>
        {children}
      </RootProvider>
    </LucideProvider>
  );
}

theme.hotKey: false turns off Fumadocs' single-key "d" theme shortcut (fumadocs-ui 16.17.8, provider/base.d.ts; Accessibility). app/layout.tsx wraps the site in Provider (Typography).

Search

Fumadocs' built-in search has a static mode: a route handler writes the index to one file at build time, and the dialog downloads it on first use (static build, built-in search).

// website/app/api/search.json/route.ts
import { createFromSource } from 'fumadocs-core/search/server';
import { source } from '@/lib/source';

export const dynamic = 'force-static';
export const revalidate = false;

export const { staticGET: GET } = createFromSource(source, { language: 'english' });
// website/components/search.tsx
'use client';
import {
  SearchDialog, SearchDialogClose, SearchDialogContent, SearchDialogHeader,
  SearchDialogIcon, SearchDialogInput, SearchDialogList, SearchDialogOverlay,
  type SharedProps,
} from 'fumadocs-ui/components/dialog/search';
import { useStaticSearch } from 'fumadocs-core/search/client';

export default function StaticSearchDialog(props: SharedProps) {
  const search = useStaticSearch({
    from: '/api/search.json',
  });
  return (
    <SearchDialog {...search} {...props}>
      <SearchDialogOverlay />
      <SearchDialogContent>
        <SearchDialogHeader>
          <SearchDialogIcon />
          <SearchDialogInput />
          <SearchDialogClose />
        </SearchDialogHeader>
        <SearchDialogList />
      </SearchDialogContent>
    </SearchDialog>
  );
}

Without from, the dialog fetches /api/search, which a static export does not write, so it returns 404 on GitHub Pages.

Social images

  • Site card: app/opengraph-image.png, 1200 × 630, made in Canva and committed; its design also serves as the GitHub social preview (Brand).
  • Per-page cards: ImageResponse from next/og in app/og/docs/[...slug]/route.tsx with generateStaticParams, written to files at build time. Fumadocs' static example does this (examples/next-static/app/og, Fumadocs: next/og). Write the card's JSX for this site rather than using Fumadocs' generateOGImage, which has its own colours. Layout: Icons and Illustration.
  • ImageResponse reads only ttf, otf and woff fonts, supports flexbox but not grid, and has a 500 KB bundle limit (ImageResponse). next/font serves woff2, so website/assets/fonts/InstrumentSans-SemiBold.ttf is committed with OFL.txt; the SIL OFL 1.1 allows redistribution with the licence.

Markdown and llms files

Route handlers write llms.txt, llms-full.txt and one content.md per page at build time; what they contain and their URLs are on Agent Experience. For the build: page.data.getText('processed') returns Markdown only when the collection sets postprocess: { includeProcessedMarkdown: true } in defineDocs; llms().full() is used as it is, and the llms.txt route writes its own list with absolute .md URLs.

The workclock package

The demo, the playground and the static week strips run the workclock package built from this repository's js/ folder: "workclock": "file:../js" in website/package.json. The demo then matches the commit it is built from, and it works before 0.1.0 is on npm. Building js/ needs Rust, the wasm32-unknown-unknown target and wasm-pack (CONTRIBUTING.md), so CI and the deploy install them.

What the package offers (js/package.json, ADR 0009):

Import Build Loads the wasm by
workclock in Node.js wasm-pack nodejs reading the file on import
workclock in a browser bundle wasm-pack bundler importing .wasm as a module, which the bundler must support
workclock/web wasm-pack web fetch after await init()

workclock/web, imported only on the pages that need it, inside a client component. init() is explicit, so the page controls when the 570 KB .wasm (local npm run build:web on 2026-10-24, uncompressed; it bundles the time zone database) is fetched, and docs pages never load it. The bundler entry would need Turbopack to handle a .wasm ES module import, which Next.js does not document.

// website/components/use-workclock.ts
'use client';
import { useEffect, useState } from 'react';

type Workclock = typeof import('workclock/web');

export function useWorkclock() {
  const [workclock, setWorkclock] = useState<Workclock | null>(null);
  const [error, setError] = useState<unknown>(null);
  useEffect(() => {
    let cancelled = false;
    import('workclock/web')
      .then(async (mod) => {
        await mod.default();
        if (!cancelled) setWorkclock(mod);
      })
      .catch((e) => {
        if (!cancelled) setError(e);
      });
    return () => {
      cancelled = true;
    };
  }, []);
  return { workclock, error };
}

What the prototype found (2026-10-24, Next.js 16.4.3 with Turbopack, static export):

  1. In the browser: next build copies workclock_bg.wasm to out/_next/static/media/ and writes its URL (/_next/static/media/workclock_bg.<hash>.wasm), so await init() with no arguments works. No copy step is needed.

  2. At build time: importing workclock (the Node.js entry) in a server component fails: Turbopack rewrites __dirname, so the entry cannot find its .wasm, and serverExternalPackages: ['workclock'] does not apply to a package linked with file:../js. workclock/web works when given the bytes:

    import { readFile } from 'node:fs/promises';
    import path from 'node:path';
    import init, { Schedule } from 'workclock/web';
    
    await init({
      module_or_path: await readFile(
        path.join(process.cwd(), 'node_modules/workclock/pkg/web/workclock_bg.wasm'),
      ),
    });

    So the static week strips and the demo's first frame are computed at build time and are in the HTML before the .wasm arrives.

  3. Still to check: the Accessibility checks on the landing page, once it exists.

A Schedule holds wasm memory: when the inputs change, free the old one (schedule.free(), or using; the class has [Symbol.dispose]).

CI: the website job

A website job in ci.yml, added to the needs list of ci-ok, runs on every pull request without deploying, so a broken site fails the PR instead of the deploy. Steps:

  1. Install Rust with the wasm target and wasm-pack (the same pinned actions as the deploy below); npm run build:web in js/.
  2. npm ci, npm run types:check and npm run build in website/.
  3. Run npx playwright install --with-deps chromium and npx playwright test; playwright.config.ts serves out/ at the site root with scripts/serve-out.mjs as its webServer.
  4. Run the lychee GitHub Action over the built HTML and .md files, with --root-dir set to website/out so root-relative links resolve.
      - uses: lycheeverse/lychee-action@<sha> # <tag>
        with:
          args: >-
            --no-progress
            --root-dir ${{ github.workspace }}/website/out
            'website/out/**/*.html' 'website/out/**/*.md'
          fail: true

Look up the lychee action's latest release tag and SHA when adding it (gh api repos/lycheeverse/lychee-action/commits/<tag> --jq .sha).

Deploy to GitHub Pages

.github/workflows/website.yml builds website/out/ and publishes it with GitHub's Pages actions (custom workflows). In the repository settings, Pages → Source must be "GitHub Actions"; the owner sets that.

It follows the repository's rules (ADR 0002, ci.yml): every action pinned by full SHA with the tag in a comment; permissions: contents: read at the top and more only where needed; persist-credentials: false; timeout-minutes on every job; zizmor and actionlint pass (the workflows job).

The SHAs for checkout, rust-toolchain, install-action and setup-node are the ones already in ci.yml. The Pages actions are new; look their SHAs up from the release tags (latest on 2026-10-24: configure-pages v6.0.0, upload-pages-artifact v5.0.0, deploy-pages v5.0.1):

gh api repos/actions/configure-pages/commits/v6.0.0 --jq .sha
gh api repos/actions/upload-pages-artifact/commits/v5.0.0 --jq .sha
gh api repos/actions/deploy-pages/commits/v5.0.1 --jq .sha
name: Website

on:
  push:
    branches: [main]
    paths:
      - "website/**"
      - "js/**"
      - "crates/workclock/**"
      - "crates/workclock-wasm/**"
      - ".github/workflows/website.yml"
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: pages
  cancel-in-progress: false

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: dtolnay/rust-toolchain@683b588c487a69d01f6c6ddf40f036ff682cc2c8 # master
        with:
          toolchain: stable
          targets: wasm32-unknown-unknown
      - uses: taiki-e/install-action@a4d3f3f761dcd95cce1b72cb61cb6d1963438177 # v2.87.30
        with:
          tool: wasm-pack
      - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0
        with:
          node-version: "24"
          package-manager-cache: false
      - run: npm run build:web
        working-directory: js
      - run: npm ci
        working-directory: website
      - uses: actions/configure-pages@<sha> # v6.0.0
      - run: npm run build
        working-directory: website
      - uses: actions/upload-pages-artifact@<sha> # v5.0.0
        with:
          path: website/out

  deploy:
    needs: build
    runs-on: ubuntu-latest
    timeout-minutes: 10
    permissions:
      pages: write
      id-token: write
    environment:
      name: github-pages
      url: ${{ steps.deployment.outputs.page_url }}
    steps:
      - id: deployment
        uses: actions/deploy-pages@<sha> # v5.0.1

Notes:

  • pages: write and id-token: write are the minimum for deploy-pages, given to the deploy job only.
  • concurrency: pages with cancel-in-progress: false lets a running deploy finish, as in Next.js' own template (nextjs/deploy-github-pages).
  • No static_site_generator input on configure-pages: it would edit next.config in CI.
  • No npm cache, as in the release workflow's npm-package job, so a poisoned cache cannot reach a deploy.
  • upload-pages-artifact skips dot-files unless include-hidden-files: true. Next.js writes assets to _next/, which starts with an underscore. GitHub's docs do not say whether an Actions deployment runs Jekyll (which ignores _ folders); after the first deploy, check that a page loads its CSS from /_next/. If not, add an empty public/.nojekyll and include-hidden-files: true.
  • After the first deploy, check that a missing URL shows 404.html.

The "Docs problem" issue form

.github/ISSUE_TEMPLATE/docs.yml, named "Docs problem", label type:docs, with a text field id: page that the site's "Report a problem with this page" link fills from the URL (Components, Feedback link). Blank issues stay off in config.yml.

Local development

cd js
npm run build:web        # writes js/pkg/web; needs Rust, wasm32-unknown-unknown and wasm-pack
cd ../website
npm ci
npm run dev              # http://localhost:3000/
npm run build            # static site in website/out/
npm run types:check      # next typegen && tsc --noEmit

Rebuild js/ after any change to the Rust code or js/. To see the built site as GitHub Pages serves it:

npm run serve            # http://localhost:4173/

website/.gitignore: .next/, out/, .source/, next-env.d.ts, test-results/, playwright-report/.

{
  "scripts": {
    "dev": "next dev",
    "build": "next build",
    "postbuild": "node scripts/copy-markdown.mjs",
    "types:check": "next typegen && tsc --noEmit",
    "test:a11y": "playwright test"
  }
}

postbuild exists only if the owner picks the /docs/<slug>.md URLs (Agent Experience).

Version policy

  • Exact versions. No ^ or ~ in website/package.json; .npmrc has save-exact=true; package-lock.json is committed and CI uses npm ci.
  • Move together. fumadocs-core and fumadocs-ui are always the same version. fumadocs-mdx has its own line (15.x) and needs fumadocs-core ^16.17.8. fumadocs-ui 16 needs next 16. Upgrade them in one PR.
  • Dependabot. An entry for /website matching the /js entry in .github/dependabot.yml. The existing github-actions entry covers the new workflows' SHAs.
  - package-ecosystem: npm
    directory: /website
    schedule:
      interval: weekly
    cooldown:
      default-days: 7
    groups:
      website:
        update-types: [minor, patch]
    commit-message:
      prefix: build
      include: scope
  • Node.js. CI uses 24; website/package.json sets "engines": { "node": ">=22" }, like js/package.json.
  • Upgrades. Read the Fumadocs and Next.js release notes, build, open the site, and check search, the theme toggle, a .md file and the demo before merging. Re-check the Fumadocs selectors listed on Design Tokens and Layout and Spacing.

Proposed, needs approval

None of these may be installed until the owner approves. Each has a way to do without it.

Package Kind What it would do Without it
fumadocs-typescript (5.6.0) npm, dev Generates the JavaScript API tables (AutoTypeTable) from js/pkg/web/workclock.d.ts. Hand-written TypeTables, checked in review.
fumadocs-python (1.0.5) npm, plus a Python command Generates the Python reference from docstrings. Marked "Experimental"; imports the package, so CI must build the wheel first. Hand-written reference pages from the docstrings.
cn npm Joins class names, as in the example's lib/cn.ts. A template string or a three-line helper.
postcss npm, dev Listed directly in the Fumadocs example. @tailwindcss/postcss already depends on it. Leave it out unless the build asks for it.

Open items

  • Proposed: scripts/copy-markdown.mjs (Agent Experience).
  • The packages in "Proposed, needs approval".

Clone this wiki locally