Skip to content
mdlake

Custom pages

Add a hand-written page to a generated site and read lake content through typed access. Covers add-page, getLakeDocument, getLakeCollection, getLakeImage, layout primitives and what check reports when content disappears.

On this page

The modules in src/pages.config.ts cover most pages. When a page needs a layout that no module offers, you write the route yourself. A hand-written page is a normal Astro file in src/pages/. It is site-owned, so refresh-content, regenerate and upgrade-ruleset never rewrite it. It still follows the main rule of the builder: it reads lake content and never copies it.

What you do

  1. Check that the route is free.
  2. Bind the content in lake.config.ts, and run npx mdlake prepare so the keys are typed.
  3. Write src/pages/<route>.astro with getLakeDocument, getLakeCollection or getLakeImage.
  4. Run npm run check and look at the page in npm run dev.

You can do this by hand or ask Claude Code with the add-page skill. See Working with AI.

Prerequisites

  • A generated site that builds. See Scaffold and build.
  • The content exists in the lake and is published. A document that is only a draft is not in the lake clone.
  • Static output. A page is plain Astro with no server code. A route that runs on the server is a different decision; see API routes.

Step 1: check the route is free

Run:

PowerShell
npx mdlake ownership --all

src/pages/ is site-owned. A route that src/pages.config.ts already produces belongs to the generated zone, and changing it is a job for plan.md and the regenerate skill, not for a hand-written page. See Ownership zones.

A file in src/pages/ wins over a route the kit injects. The kit injects [...page] (every page of pages.config.ts), 404 and robots.txt. A src/pages/404.astro of your own replaces the kit’s 404 page. A page of yours at the same address as a pages.config.ts route is a conflict, so pick another address or remove the route from the plan.

Step 2: bind the content

Content reaches your page through the keys in lake.config.ts: documents (a key bound to a document ID), collections (a key bound to a query such as { folder, tags, sort }) and slots (a name that the site type defines, bound to a document, a collection or an image). Never write an ID into a page. The binding is the single place that knows it.

lake.config.ts is generated from plan.md. There are two ways to add a binding:

WayWhen
Add it to the plan (documents: or collections: in the site block), then run plan-to-config.mjsYou want plan.md to stay the source of truth.
Edit the defineLake call by hand and run npx mdlake adopt lake.config.tsYou accept that the plan no longer generates this file.

The add-page skill tells you that a binding added by hand needs adopt. The ownership hash of lake.config.ts covers only its defineLake call, where the bindings live. A hand edit inside that call counts as drift, so adopt the file or move the binding into the plan. An edit to siteTypeSlots, which sits next to the call, is not drift.

To find the document, search the lake clone:

PowerShell
node "<plugin>\scripts\lake-search.mjs" . pricing plans --limit 8

Then write the types:

PowerShell
npx mdlake prepare

prepare writes .mdlake/types/lake.d.ts. It fills the LakeKeys interface of @mdlake/site-kit/content with the documents, collections and image slots of your lake.config.ts. After that a key that the config does not have is a type error in your editor and in astro check. The file is ignored by Git. npm run dev, npm run build and npm run check run prepare too, so you rarely call it yourself.

Step 3: write the page

Astro
---
// src/pages/pricing.astro
import Layout from '@mdlake/site-kit/layouts/Layout.astro'
import { getLakeCollection, getLakeDocument } from '@mdlake/site-kit/content'

const intro = await getLakeDocument('pricing')
const plans = await getLakeCollection('plans')
const body = await intro.html()
---

<Layout title={intro.title} description={intro.summary}>
  <article class="mdl-column">
    <h1>{intro.title}</h1>
    <p>{intro.summary}</p>
    <div set:html={body} />
  </article>

  <ul class="mdl-wide mdl-cards">
    {plans.map((plan) => (
      <li>
        <h2>{plan.route ? <a href={plan.route}>{plan.title}</a> : plan.title}</h2>
        <p>{plan.summary}</p>
      </li>
    ))}
  </ul>
</Layout>

The keys pricing and plans come from your lake.config.ts. Use your own.

The three helpers

All three come from @mdlake/site-kit/content.

HelperReturnsFails the build when
getLakeDocument(key)One LakeDocumentThe key is not a document or a document slot, or its document is missing or not published at the locked commit.
getLakeCollection(key)An array of LakeDocument, in the order of the collection’s queryThe key is not a collection or a collection slot. An empty collection gives an empty array, not an error.
getLakeImage(slot)The image data of an asset slot such as logo or ogImageThe slot is not bound to an asset, or the asset is not an image of the lake.

The error text names the key and the document ID, for example getLakeDocument('pricing'): lake.config.ts binds "pricing" to 01JBXQ..., but that document is missing or not published in the lake.

What a LakeDocument holds

FieldMeaning
keyThe key you asked for.
idThe document ID.
title, summary, tagsThe front matter.
slugThe slug, or undefined.
routeThe address that the site publishes the document at, when a page of pages.config.ts shows it. undefined otherwise. Check it before you link.
shapeThe document read once into the pieces that modules use: sections, lists, quotes, figures. Use it with the reading rules of Site types.
imageThe lead image, with the crop, focal point and alt text of its sidecar, or undefined.
html()An async function that returns the Markdown body as safe HTML. Links are resolved, a link without a page becomes plain text, and images are served like LakeImage does.

Treat every optional field as possibly missing. A page that renders a sensible fallback or nothing keeps working when an editor leaves a summary empty.

Images

Show an image with the kit’s LakeImage, which needs alt:

Astro
---
import LakeImage from '@mdlake/site-kit/components/LakeImage.astro'
import { getLakeImage } from '@mdlake/site-kit/content'

const logo = await getLakeImage('logo')
---

<LakeImage asset={logo} alt={logo.alt} variant="logo" />

LakeImage writes width, height and the focal point, and it serves the file from the site’s own address. Its variant is hero (the default), logo (at most 5rem tall) or inline (at most 20rem tall). Other props are aspect, sizes, loading and letterbox. An image of a lead document is doc.image. Pass it as asset in the same way.

Note

Use alt text from the sidecar when it has any. Pass an empty string only for an image that is purely decoration.

Layout, spacing and colour

  • Wrap the page in the kit Layout. It adds the header, footer, SEO tags, skip link and the preview banner. The title prop is the lake document’s title and description its summary. Other props are image, bareTitle, noindex and type (website or article).
  • Use the layout primitives, not your own widths. .mdl-column is the narrow centred column (42rem) for running text. .mdl-wide is the wide one (72rem) for grids and heroes. .mdl-cards and .mdl-tiles lay out card grids. See Site types.
  • Use the spacing tokens for the rhythm (mt-section, gap-block, and so on) and the colour tokens of global.css. Do not write hard-coded colours or pixel spacing.
  • Use one <h1> per page.

Step 4: check it

PowerShell
npm run check
npm run dev

The check includes the typed content scan. It reads the site-owned files under src/ and finds every call to the three helpers. It then reports:

FindingSeverity
A key that lake.config.ts does not have, or the wrong helper for it (getLakeDocument for a collection)Error. The message names the key and the files.
A call whose key is not a plain string literal, such as getLakeDocument(name) or a template string with ${}Warning. The key cannot be checked, and refresh-content cannot tell you that the page is affected.
A bound document that is missing or unpublished at the locked commitError. The message names the key, the document ID and the site-owned files that read it.

Use string literals. If you must choose a key at run time, keep a literal list of the keys next to it.

When content disappears

An editor can unpublish or delete a document that your page reads. The site does not notice until you move the lock, because a snapshot site keeps the content it was built with. When you run Refresh content:

  • The content report names the page: “src/pages/pricing.astro reads pricing, which was removed.”
  • npm run check with the new commit fails and names the key, the ID and the file.
  • The skill proposes a fix in your file and applies it only if you agree.

Plan for it. Render a fallback or nothing for an optional document, or bind the page to a collection so that one missing item only makes the list shorter. Safe content refresh covers the full flow.

Do and don’t

DoDon’t
Read text from the lake through the helpersPaste lake text into an .astro file
Write headings and button labels that are site chrome in your pageHard-code a price, a quote or a statistic
Link to doc.route when it is setBuild a link from a title or a guessed slug
Keep the page staticAdd a fetch to a server endpoint without agreeing the hosting change

What can go wrong

SymptomCause and fix
A type error: the key is not assignableThe key is not in lake.config.ts, or .mdlake/types/lake.d.ts is old. Run npx mdlake prepare and check the spelling.
The build stops with “binds … but that document is missing or not published”The document was unpublished, deleted or never reached the clone. Rebind it, or run refresh-content.
Two pages at one addressA file in src/pages and a route from pages.config.ts collide. Change one of the addresses.
The page works in dev and the check warns about a literalA key passed as a variable. Use a string literal.
An image has no alt textThe sidecar has none. Ask the editor to add it in the media library.

What’s next