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
- Check that the route is free.
- Bind the content in
lake.config.ts, and runnpx mdlake prepareso the keys are typed. - Write
src/pages/<route>.astrowithgetLakeDocument,getLakeCollectionorgetLakeImage. - Run
npm run checkand look at the page innpm 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:
npx mdlake ownership --allsrc/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:
| Way | When |
|---|---|
Add it to the plan (documents: or collections: in the site block), then run plan-to-config.mjs | You want plan.md to stay the source of truth. |
Edit the defineLake call by hand and run npx mdlake adopt lake.config.ts | You 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:
node "<plugin>\scripts\lake-search.mjs" . pricing plans --limit 8Then write the types:
npx mdlake prepareprepare 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
---
// 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.
| Helper | Returns | Fails the build when |
|---|---|---|
getLakeDocument(key) | One LakeDocument | The 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 query | The 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 ogImage | The 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
| Field | Meaning |
|---|---|
key | The key you asked for. |
id | The document ID. |
title, summary, tags | The front matter. |
slug | The slug, or undefined. |
route | The address that the site publishes the document at, when a page of pages.config.ts shows it. undefined otherwise. Check it before you link. |
shape | The document read once into the pieces that modules use: sections, lists, quotes, figures. Use it with the reading rules of Site types. |
image | The 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:
---
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. Thetitleprop is the lake document’s title anddescriptionits summary. Other props areimage,bareTitle,noindexandtype(websiteorarticle). - Use the layout primitives, not your own widths.
.mdl-columnis the narrow centred column (42rem) for running text..mdl-wideis the wide one (72rem) for grids and heroes..mdl-cardsand.mdl-tileslay out card grids. See Site types. - Use the spacing tokens for the rhythm (
mt-section,gap-block, and so on) and the colour tokens ofglobal.css. Do not write hard-coded colours or pixel spacing. - Use one
<h1>per page.
Step 4: check it
npm run check
npm run devThe 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:
| Finding | Severity |
|---|---|
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 commit | Error. 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.astroreadspricing, which was removed.” npm run checkwith 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
| Do | Don’t |
|---|---|
| Read text from the lake through the helpers | Paste lake text into an .astro file |
| Write headings and button labels that are site chrome in your page | Hard-code a price, a quote or a statistic |
Link to doc.route when it is set | Build a link from a title or a guessed slug |
| Keep the page static | Add a fetch to a server endpoint without agreeing the hosting change |
What can go wrong
| Symptom | Cause and fix |
|---|---|
| A type error: the key is not assignable | The 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 address | A 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 literal | A key passed as a variable. Use a string literal. |
| An image has no alt text | The sidecar has none. Ask the editor to add it in the media library. |
What’s next
- Custom components for reusable pieces and for overriding a kit module.
- Safe content refresh for what happens to your page when the content moves.
