Skip to content

Public Pages

Public pages are standard Astro pages that fetch content from the local API.

New projects ship a minimal route structure:

src/pages/
index.astro # home page
[slug].astro # pages by slug

[slug].astro fetches a document from the pages collection and renders its content-type body field with <ContentRenderer>. Here is the full pattern — preview-aware fetch, cache tagging, and live-preview attributes in one place:

src/pages/[slug].astro
---
import PublicLayout from "@/layouts/PublicLayout.astro";
import ContentRenderer from "@/components/ContentRenderer.astro";
import { cms } from "@/cms/.generated/api";
import { cacheTags } from "@kidecms/core";
const isPreview = Astro.url.searchParams.has("preview");
const doc = await cms.pages.findOne({ slug: Astro.params.slug!, status: isPreview ? "any" : "published" });
if (!doc) return Astro.redirect("/404");
if (isPreview) Astro.cache.set(false); // never cache draft responses
else Astro.cache.set({ tags: cacheTags("pages", doc._id) });
---
<PublicLayout title={doc.title}>
<h1 data-cms="title">{doc.title}</h1>
<div data-cms="body">
<ContentRenderer content={doc.body} />
</div>
</PublicLayout>

As you add collections, give each content type its own route file (blog/[slug].astro for a posts collection, and so on) following the same pattern.

The admin’s live preview updates the public page as the editor types. Pages opt in with two things, both visible in the snippet above:

  • Fetch with status: isPreview ? "any" : "published" so draft content renders when ?preview is in the URL.
  • Add data-cms="{fieldName}" attributes to the elements that render CMS fields. The preview client is auto-injected on every page but activates only when ?preview is present; it updates matching elements over a BroadcastChannel without a reload.

Preview requests also skip caching (the if (!isPreview) guard), so editors always see the latest saved content.

All content queries go through the typed local API. This gives you full type safety and access to all query options.

---
import { cms } from "@/cms/.generated/api";
// List published posts
const posts = await cms.posts.find({
status: "published",
sort: { field: "_createdAt", direction: "desc" },
limit: 10,
});
// Find by slug
const post = await cms.posts.findOne({ slug: "hello-world" });
// Find by ID
const post = await cms.posts.findById("abc123");
---

See Local API for the full query API.

Content pages use Astro’s route caching with tag-based invalidation. Three parts: the Astro config that enables caching, the tags a page sets, and the hooks that invalidate them.

Route caching needs a cache provider and route rules in astro.config.mjs — without them, Astro.cache.set() is a no-op:

import { defineConfig, memoryCache } from "astro/config";
export default defineConfig({
// ...
cache: {
provider: memoryCache(),
},
routeRules: {
"/": { maxAge: 86400, swr: 3600 },
"/blog/**": { maxAge: 86400, swr: 3600 },
},
});
---
if (isPreview) {
// Skipping set() is not enough: route rules alone activate caching, so a
// cached draft response could be served to anonymous visitors. Opt out.
Astro.cache.set(false);
} else {
Astro.cache.set({ tags: cacheTags("posts", doc._id) });
}
---

cacheTags generates the tag array — cacheTags("posts", doc._id) returns ["posts", "post:abc123"], equivalent to writing the raw array by hand.

Invalidation is code you write: nothing invalidates per-document tags automatically. Add after* hooks to the collection and call context.cache?.invalidate():

import { cacheTags } from "@kidecms/core";
hooks: {
afterPublish(doc, context) {
context.cache?.invalidate({ tags: cacheTags("posts", String(doc._id)) });
},
},

The one built-in case: deleteMany invalidates the collection tag (posts) on its own. Everything else — publish, update, unpublish — needs a hook. See Hooks for the full pattern.

For landing-page-style content, define a collection with a blocks field and render it with <BlockRenderer>:

---
// e.g. src/pages/[...slug].astro
import PublicLayout from "@/layouts/PublicLayout.astro";
import BlockRenderer from "@/components/BlockRenderer.astro";
import { cms } from "@/cms/.generated/api";
import { cacheTags, parseBlocks } from "@kidecms/core";
const isPreview = Astro.url.searchParams.has("preview");
const doc = await cms.landing.findOne({ slug: Astro.params.slug!, status: isPreview ? "any" : "published" });
if (!doc) return Astro.redirect("/404");
if (isPreview) Astro.cache.set(false);
else Astro.cache.set({ tags: cacheTags("landing", doc._id) });
const blocks = parseBlocks(doc.blocks);
---
<PublicLayout title={doc.title}>
<h1>{doc.title}</h1>
<BlockRenderer blocks={blocks} />
</PublicLayout>

<BlockRenderer> maps each block type to an Astro component in src/components/blocks/ (PascalCase filename → camelCase block type — Hero.astro renders hero blocks). Block fields are passed as props:

src/components/blocks/Hero.astro
---
const { eyebrow, heading, body, ctaLabel, ctaHref } = Astro.props;
---
<section>
{eyebrow && <p>{eyebrow}</p>}
<h2>{heading}</h2>
{body && <p>{body}</p>}
{ctaLabel && ctaHref && <a href={ctaHref}>{ctaLabel}</a>}
</section>

For fields that store JSON arrays (repeaters, image lists), use the parseList helper:

---
import { parseList } from "@kidecms/core";
const { heading, items: rawItems } = Astro.props;
const items = parseList<{ title?: string; description?: string }>(rawItems);
---
<h2>{heading}</h2>
{
items.map((item) => (
<div>
<p>{item.title}</p>
<p>{item.description}</p>
</div>
))
}

Block types without a matching component render generically. No code is needed for basic blocks.

Use cmsImage and cmsSrcset for optimized images:

---
import { cmsImage, cmsSrcset } from "@kidecms/core";
---
<!-- Single optimized image -->
<img src={cmsImage(doc.image, 800)} alt={doc.title} />
<!-- Responsive with srcset -->
<img
src={cmsImage(doc.image, 1024)}
srcset={cmsSrcset(doc.image)}
sizes="(max-width: 640px) 100vw, 640px"
alt={doc.title}
/>

For cropped, art-directed images (e.g. a wide hero on desktop recomposed for mobile), use <CmsPicture>. It resolves the asset’s focal point, crops server-side, emits AVIF + WebP sources, and sets width/height to prevent layout shift:

---
import CmsPicture from "@/components/CmsPicture.astro";
---
<CmsPicture
src={doc.image}
alt={doc.title}
preset="banner"
art={[{ media: "(max-width: 640px)", preset: "heroMobile" }]}
loading="eager"
fetchpriority="high"
/>

Images are transformed on-demand (Sharp) and cached to disk. See Assets for cropping, focal points, presets, and the full <CmsPicture> reference.