create-headroom-site 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-headroom-site",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Scaffold a new Headroom CMS frontend site (Astro or Next.js)",
5
5
  "type": "module",
6
6
  "license": "PolyForm-Noncommercial-1.0.0",
@@ -0,0 +1,364 @@
1
+ # Headroom CMS - Astro Site
2
+
3
+ This is an Astro site powered by [Headroom CMS](https://github.com/headroom-cms), a serverless headless CMS. Content is managed in the Headroom admin UI and fetched at build time via the `@headroom-cms/api` SDK.
4
+
5
+ ## Tech Stack
6
+
7
+ - **Astro** (static output) with content collections
8
+ - **@headroom-cms/api** SDK for data fetching and block rendering
9
+ - **Tailwind CSS v4** for styling
10
+
11
+ ## Project Structure
12
+
13
+ ```
14
+ src/
15
+ ├── content.config.ts # Astro content collections backed by Headroom loader
16
+ ├── lib/
17
+ │ ├── client.ts # Singleton HeadroomClient instance
18
+ │ ├── links.ts # Content link resolver (maps refs to URL paths)
19
+ │ └── schemas.ts # Auto-generated Zod schemas (run generate:schemas)
20
+ ├── layouts/
21
+ │ └── BaseLayout.astro # Shell with Header, Footer, <slot />
22
+ ├── components/ # Reusable Astro components
23
+ ├── pages/
24
+ │ ├── index.astro # Homepage
25
+ │ ├── blog/
26
+ │ │ ├── index.astro # Blog listing
27
+ │ │ └── [slug].astro # Blog post detail
28
+ │ ├── [...slug].astro # Dynamic pages from "pages" collection
29
+ │ └── 404.astro
30
+ └── styles/
31
+ └── global.css # Tailwind imports
32
+ ```
33
+
34
+ ## Environment Variables
35
+
36
+ Copy `_env.example` to `.env` and fill in your Headroom credentials:
37
+
38
+ ```bash
39
+ HEADROOM_URL=https://your-headroom-url.cloudfront.net
40
+ HEADROOM_SITE=your-site.com
41
+ HEADROOM_API_KEY=headroom_xxxxx
42
+ HEADROOM_IMAGE_SIGNING_SECRET=your-secret # optional, for image transforms
43
+ ```
44
+
45
+ ## Commands
46
+
47
+ ```bash
48
+ npm run dev # Start Astro dev server
49
+ npm run build # Build static site
50
+ npm run preview # Preview built site
51
+ npm run generate:schemas # Generate Zod schemas from Headroom collections
52
+ ```
53
+
54
+ ## Default Collections
55
+
56
+ New Headroom sites are scaffolded with three collections:
57
+
58
+ | Collection | Type | Description |
59
+ |------------|------|-------------|
60
+ | `posts` | Standard | Blog posts with `author` (text) and `content` (blocks) fields; use the `featured` tag to mark featured posts |
61
+ | `pages` | Standard | Generic pages with a `content` (blocks) field |
62
+ | `site-settings` | Singleton | Site name, tagline, footer text, and menu items |
63
+
64
+ ## How Data Fetching Works
65
+
66
+ This site uses **two approaches** to fetch content:
67
+
68
+ ### 1. Astro Content Collections (for listings and metadata)
69
+
70
+ Content collections are defined in `src/content.config.ts` using `headroomLoader()`. This loads content metadata into Astro's content layer at build time, enabling `getCollection()` and `getEntry()`:
71
+
72
+ ```ts
73
+ // src/content.config.ts
74
+ import { defineCollection } from "astro:content";
75
+ import { headroomLoader } from "@headroom-cms/api/astro";
76
+
77
+ export const collections = {
78
+ posts: defineCollection({
79
+ loader: headroomLoader({ collection: "posts" }),
80
+ }),
81
+ pages: defineCollection({
82
+ loader: headroomLoader({ collection: "pages", bodies: true }),
83
+ }),
84
+ };
85
+ ```
86
+
87
+ Use `bodies: true` when you need the full block content in the content layer (e.g., for pages). Omit it for listings where you only need metadata.
88
+
89
+ ```astro
90
+ ---
91
+ import { getCollection, getEntry } from "astro:content";
92
+
93
+ // List all posts
94
+ const posts = await getCollection("posts");
95
+
96
+ // Get a singleton
97
+ const settings = await getEntry("site-settings", "site-settings");
98
+ const siteName = settings?.data.body?.siteName as string;
99
+ ---
100
+ ```
101
+
102
+ ### 2. Direct SDK Client (for full content with body and refs)
103
+
104
+ For detail pages that need the full block body and content references (`_refs`), use the SDK client directly:
105
+
106
+ ```astro
107
+ ---
108
+ import { getClient } from "../lib/client";
109
+ import type { Block, RefsMap } from "@headroom-cms/api";
110
+
111
+ const client = getClient();
112
+ const post = await client.getContentBySlug("posts", "my-post");
113
+
114
+ const blocks = (post.body?.content || []) as Block[];
115
+ const refs = (post._refs || {}) as RefsMap;
116
+ ---
117
+ ```
118
+
119
+ ### SDK Client Methods
120
+
121
+ | Method | Returns | Description |
122
+ |--------|---------|-------------|
123
+ | `listContent(collection, opts?)` | `{ items, cursor, hasMore }` | List published content with pagination/filtering |
124
+ | `getContent(contentId)` | `ContentItem` | Get single item by ID (includes body + refs) |
125
+ | `getContentBySlug(collection, slug)` | `ContentItem \| undefined` | Look up by slug |
126
+ | `getSingleton(collection)` | `ContentItem` | Get singleton content (e.g. site settings) |
127
+ | `listCollections()` | `{ items }` | List all collections |
128
+ | `getCollection(name)` | `Collection` | Get collection schema |
129
+ | `mediaUrl(path)` | `string` | Full URL for a media path |
130
+ | `transformUrl(path, opts?)` | `string` | Signed image transform URL |
131
+
132
+ ### listContent Options
133
+
134
+ ```ts
135
+ const { items, cursor, hasMore } = await client.listContent("posts", {
136
+ limit: 10,
137
+ cursor: "...", // pagination
138
+ sort: "published_desc", // published_desc | published_asc | title_asc | title_desc
139
+ before: 1700000000, // unix timestamp filter
140
+ after: 1690000000,
141
+ relatedTo: "01ABC", // reverse relationship query
142
+ relField: "author", // filter reverse query to specific field
143
+ });
144
+ ```
145
+
146
+ ## Rendering Blocks
147
+
148
+ Content bodies are arrays of [BlockNote](https://www.blocknotejs.org/) blocks. Use the Astro `BlockRenderer` component to render them as semantic HTML with zero client-side JavaScript:
149
+
150
+ ```astro
151
+ ---
152
+ import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
153
+ import type { Block, RefsMap } from "@headroom-cms/api";
154
+
155
+ const blocks = (post.body?.content || []) as Block[];
156
+ const refs = (post._refs || {}) as RefsMap;
157
+ ---
158
+
159
+ <div class="prose">
160
+ <BlockRenderer
161
+ blocks={blocks}
162
+ refs={refs}
163
+ resolveContentLink={(ref) => `/${ref.collection}/${ref.slug}`}
164
+ transformImage={(path) => client.transformUrl(path, { width: 1200, format: "webp" })}
165
+ />
166
+ </div>
167
+ ```
168
+
169
+ ### BlockRenderer Props
170
+
171
+ | Prop | Type | Description |
172
+ |------|------|-------------|
173
+ | `blocks` | `Block[]` | Block content array from `post.body.content` |
174
+ | `refs` | `RefsMap?` | Content reference map from `post._refs` (resolves `headroom://` links) |
175
+ | `resolveContentLink` | `(ref: PublicContentRef) => string` | Maps content references to URL paths |
176
+ | `transformImage` | `(path: string) => string` | Transforms image paths (e.g., for responsive images) |
177
+ | `baseUrl` | `string?` | Base URL for media (defaults to `HEADROOM_URL` env var) |
178
+ | `class` | `string?` | CSS class for the wrapper `<div>` |
179
+
180
+ ### Block Types
181
+
182
+ Built-in block types: `paragraph`, `heading`, `image`, `codeBlock`, `bulletListItem`, `numberedListItem`, `checkListItem`, `table`
183
+
184
+ Astro block components render semantic HTML (`<p>`, `<h1>`-`<h6>`, `<ul>`, `<ol>`, `<figure>`, `<table>`) that work naturally with Tailwind's `prose` class.
185
+
186
+ ### Custom Block Rendering in Astro
187
+
188
+ To add custom block types or override built-in rendering, create your own renderer that wraps the individual block components:
189
+
190
+ ```astro
191
+ ---
192
+ // src/components/CustomBlockRenderer.astro
193
+ import Paragraph from "@headroom-cms/api/blocks/Paragraph.astro";
194
+ import Heading from "@headroom-cms/api/blocks/Heading.astro";
195
+ import Image from "@headroom-cms/api/blocks/Image.astro";
196
+ import CodeBlock from "@headroom-cms/api/blocks/CodeBlock.astro";
197
+ import BulletList from "@headroom-cms/api/blocks/BulletList.astro";
198
+ import NumberedList from "@headroom-cms/api/blocks/NumberedList.astro";
199
+ import CheckList from "@headroom-cms/api/blocks/CheckList.astro";
200
+ import Table from "@headroom-cms/api/blocks/Table.astro";
201
+ import Fallback from "@headroom-cms/api/blocks/Fallback.astro";
202
+ import MyCallout from "./MyCallout.astro";
203
+
204
+ const { blocks, refs, resolveContentLink, transformImage } = Astro.props;
205
+ ---
206
+
207
+ {blocks.map((block) => {
208
+ if (block.type === "callout") return <MyCallout block={block} />;
209
+ if (block.type === "paragraph") return <Paragraph block={block} refs={refs} resolveContentLink={resolveContentLink} />;
210
+ if (block.type === "heading") return <Heading block={block} refs={refs} resolveContentLink={resolveContentLink} />;
211
+ if (block.type === "image") return <Image block={block} transformImage={transformImage} />;
212
+ if (block.type === "codeBlock") return <CodeBlock block={block} />;
213
+ if (block.type === "bulletListItem") return <BulletList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
214
+ if (block.type === "numberedListItem") return <NumberedList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
215
+ if (block.type === "checkListItem") return <CheckList block={block} refs={refs} resolveContentLink={resolveContentLink} />;
216
+ if (block.type === "table") return <Table block={block} refs={refs} resolveContentLink={resolveContentLink} />;
217
+ return <Fallback block={block} />;
218
+ })}
219
+ ```
220
+
221
+ ## Content Links
222
+
223
+ Rich text can contain links to other Headroom content using `headroom://content/{collection}/{contentId}` URLs. The `_refs` map on each content item resolves these to metadata (slug, title, collection, published status).
224
+
225
+ The `BlockRenderer` resolves these automatically using the `resolveContentLink` prop. Define your URL mapping in `src/lib/links.ts`:
226
+
227
+ ```ts
228
+ import type { PublicContentRef } from "@headroom-cms/api";
229
+
230
+ export function resolveContentLink(ref: PublicContentRef): string {
231
+ switch (ref.collection) {
232
+ case "posts":
233
+ return `/blog/${ref.slug}`;
234
+ default:
235
+ return `/${ref.slug}`;
236
+ }
237
+ }
238
+ ```
239
+
240
+ ## Relationships
241
+
242
+ Collections can define relationships to other collections. Access them on content items:
243
+
244
+ ```ts
245
+ // Forward: get a project's related artists
246
+ const project = await client.getContent("01ABC");
247
+ const artists = project.relationships?.artists; // ContentRef[]
248
+
249
+ // Reverse: find all projects referencing an artist
250
+ const { items } = await client.listContent("projects", {
251
+ relatedTo: "01ARTIST",
252
+ relField: "artists",
253
+ });
254
+ ```
255
+
256
+ ## Images and Media
257
+
258
+ Media paths in content are relative (e.g., `/media/site/id/original.jpg`). Use the client to build full URLs:
259
+
260
+ ```ts
261
+ const client = getClient();
262
+
263
+ // Full URL for original image
264
+ client.mediaUrl(post.data.coverUrl);
265
+
266
+ // Signed transform URL (resized + webp)
267
+ client.transformUrl(post.data.coverUrl, { width: 800, format: "webp" });
268
+ ```
269
+
270
+ ### Transform Options
271
+
272
+ | Option | Type | Description |
273
+ |--------|------|-------------|
274
+ | `width` | `number` | Target width in pixels |
275
+ | `height` | `number` | Target height in pixels |
276
+ | `fit` | `"cover" \| "contain" \| "fill" \| "inside" \| "outside"` | Resize fit mode |
277
+ | `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | Output format |
278
+ | `quality` | `number` | 1-100 quality |
279
+
280
+ Transforms require `HEADROOM_IMAGE_SIGNING_SECRET` in `.env`. Without it, `transformUrl()` falls back to `mediaUrl()`.
281
+
282
+ ## Dev Refresh
283
+
284
+ The `headroomDevRefresh()` integration in `astro.config.mjs` polls Headroom for content version changes and triggers automatic reloads during `astro dev`.
285
+
286
+ ## Schema Generation
287
+
288
+ Run `npm run generate:schemas` to generate Zod schemas from your Headroom collections into `src/lib/schemas.ts`. This provides type-safe validation in content collections. Regenerate after changing collection schemas in the admin UI.
289
+
290
+ ## Adding a New Collection
291
+
292
+ 1. Create the collection in the Headroom admin UI
293
+ 2. Add it to `src/content.config.ts`:
294
+ ```ts
295
+ projects: defineCollection({
296
+ loader: headroomLoader({ collection: "projects" }),
297
+ }),
298
+ ```
299
+ 3. Create pages (listing + detail) under `src/pages/`
300
+ 4. Update `src/lib/links.ts` to map the new collection to URL paths
301
+ 5. Run `npm run generate:schemas` to update type definitions
302
+
303
+ ## Adding a New Page for Existing Content
304
+
305
+ Example: adding a page that lists posts filtered by a relationship.
306
+
307
+ ```astro
308
+ ---
309
+ // src/pages/author/[id].astro
310
+ import { getClient } from "../../lib/client";
311
+ import BaseLayout from "../../layouts/BaseLayout.astro";
312
+ import PostCard from "../../components/PostCard.astro";
313
+
314
+ const { id } = Astro.params;
315
+ const client = getClient();
316
+ const { items: posts } = await client.listContent("posts", {
317
+ relatedTo: id,
318
+ relField: "author",
319
+ });
320
+ ---
321
+
322
+ <BaseLayout title="Author Posts">
323
+ <div class="max-w-5xl mx-auto py-12 px-6">
324
+ <div class="grid md:grid-cols-3 gap-8">
325
+ {posts.map((post) => (
326
+ <PostCard title={post.title} snippet={post.snippet} slug={post.slug} />
327
+ ))}
328
+ </div>
329
+ </div>
330
+ </BaseLayout>
331
+ ```
332
+
333
+ ## Error Handling
334
+
335
+ ```ts
336
+ import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
337
+
338
+ try {
339
+ const post = await client.getContent("nonexistent");
340
+ } catch (e) {
341
+ if (e instanceof HeadroomError) {
342
+ console.log(e.status); // 404
343
+ console.log(e.code); // "CONTENT_NOT_FOUND"
344
+ }
345
+ }
346
+ ```
347
+
348
+ ## Key Types
349
+
350
+ ```ts
351
+ import type {
352
+ ContentItem, // Full content item with body, refs, relationships
353
+ ContentMetadata, // Content without body (listings)
354
+ Block, // A BlockNote block
355
+ RefsMap, // Map of content ID -> PublicContentRef
356
+ PublicContentRef, // { contentId, collection, slug, title, published }
357
+ ContentRef, // Relationship reference
358
+ Collection, // Collection schema
359
+ FieldDef, // Field definition in a collection
360
+ RelationshipDef, // Relationship definition
361
+ HeadroomConfig, // Client configuration
362
+ TransformOptions, // Image transform options
363
+ } from "@headroom-cms/api";
364
+ ```
@@ -5,15 +5,17 @@
5
5
  "scripts": {
6
6
  "dev": "astro dev",
7
7
  "build": "astro build",
8
- "preview": "astro preview"
8
+ "preview": "astro preview",
9
+ "generate:schemas": "bash scripts/generate-schemas.sh"
9
10
  },
10
11
  "dependencies": {
11
12
  "astro": "^5.0.0",
12
- "@headroom-cms/api": "^0.1.3"
13
+ "@headroom-cms/api": "^0.1.4"
13
14
  },
14
15
  "devDependencies": {
15
16
  "@tailwindcss/vite": "^4.0.0",
16
17
  "tailwindcss": "^4.0.0",
18
+ "tsx": "^4.0.0",
17
19
  "typescript": "^5.9.3"
18
20
  }
19
21
  }
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Generates Zod schemas from Headroom collection definitions.
5
+ # Requires: .env with HEADROOM_* vars, API accessible.
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ SITE_DIR="$(dirname "$SCRIPT_DIR")"
9
+
10
+ if [ ! -f "$SITE_DIR/.env" ]; then
11
+ echo "Error: .env not found. Create a .env file with HEADROOM_URL, HEADROOM_SITE, and HEADROOM_API_KEY." >&2
12
+ exit 1
13
+ fi
14
+
15
+ cd "$SITE_DIR"
16
+ npx tsx scripts/generate-schemas.ts
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Generates Zod schemas from Headroom collection definitions.
4
+ * Reads connection details from .env and writes src/lib/schemas.ts.
5
+ *
6
+ * Usage: npx tsx scripts/generate-schemas.ts
7
+ */
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import { resolve, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { HeadroomClient } from "@headroom-cms/api";
13
+ import { generateZodSchemas } from "@headroom-cms/api/codegen";
14
+ import { configFromEnv } from "@headroom-cms/api/astro";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const siteDir = resolve(__dirname, "..");
18
+
19
+ const config = configFromEnv();
20
+ if (!config.site || !config.apiKey) {
21
+ console.error("Error: HEADROOM_SITE and HEADROOM_API_KEY must be set in .env");
22
+ process.exit(1);
23
+ }
24
+
25
+ const client = new HeadroomClient(config);
26
+ const output = await generateZodSchemas(client);
27
+
28
+ const outPath = resolve(siteDir, "src/lib/schemas.ts");
29
+ writeFileSync(outPath, output, "utf-8");
30
+ console.log(`Schemas generated at src/lib/schemas.ts`);
@@ -1,5 +1,13 @@
1
+ ---
2
+ import { getEntry } from "astro:content";
3
+
4
+ const settingsEntry = await getEntry("site-settings", "site-settings");
5
+ const footerText = (settingsEntry?.data.body?.footerText as string) || "";
6
+ ---
7
+
1
8
  <footer class="border-t border-gray-100 py-8 mt-16">
2
9
  <div class="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
10
+ {footerText && <p class="mb-2">{footerText}</p>}
3
11
  <p>
4
12
  Powered by <a href="https://headroom.dev" class="hover:underline">Headroom CMS</a>
5
13
  &middot; Built with <a href="https://astro.build" class="hover:underline">Astro</a>
@@ -3,6 +3,7 @@ import { getEntry } from "astro:content";
3
3
 
4
4
  const settingsEntry = await getEntry("site-settings", "site-settings");
5
5
  const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
6
+ const menuItems = (settingsEntry?.data.body?.menuItems as Array<{ label: string; href: string }>) || [];
6
7
  ---
7
8
 
8
9
  <header class="border-b border-gray-100 bg-white sticky top-0 z-50">
@@ -10,9 +11,12 @@ const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
10
11
  <a href="/" class="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
11
12
  {siteName}
12
13
  </a>
13
- <ul class="flex items-center gap-6">
14
- <li><a href="/" class="text-sm font-medium text-gray-600 hover:text-gray-900">Home</a></li>
15
- <li><a href="/blog" class="text-sm font-medium text-gray-600 hover:text-gray-900">Blog</a></li>
16
- </ul>
14
+ {menuItems.length > 0 && (
15
+ <ul class="flex items-center gap-6">
16
+ {menuItems.map((item) => (
17
+ <li><a href={item.href} class="text-sm font-medium text-gray-600 hover:text-gray-900">{item.label}</a></li>
18
+ ))}
19
+ </ul>
20
+ )}
17
21
  </nav>
18
22
  </header>
@@ -1,14 +1,21 @@
1
1
  import { defineCollection } from "astro:content";
2
2
  import { headroomLoader } from "@headroom-cms/api/astro";
3
3
 
4
+ let schemas: Record<string, import("zod").ZodTypeAny> = {};
5
+ try {
6
+ schemas = await import("./lib/schemas");
7
+ } catch {
8
+ // schemas not yet generated — collections still work, just without validation
9
+ }
10
+
4
11
  export const collections = {
5
12
  posts: defineCollection({
6
- loader: headroomLoader({ collection: "posts" }),
13
+ loader: headroomLoader({ collection: "posts", schema: schemas.postsSchema }),
7
14
  }),
8
15
  pages: defineCollection({
9
- loader: headroomLoader({ collection: "pages", bodies: true }),
16
+ loader: headroomLoader({ collection: "pages", bodies: true, schema: schemas.pagesSchema }),
10
17
  }),
11
18
  "site-settings": defineCollection({
12
- loader: headroomLoader({ collection: "site-settings", bodies: true }),
19
+ loader: headroomLoader({ collection: "site-settings", bodies: true, schema: schemas.siteSettingsSchema }),
13
20
  }),
14
21
  };
@@ -8,10 +8,12 @@ import { resolveContentLink } from "../lib/links";
8
8
 
9
9
  export async function getStaticPaths() {
10
10
  const pages = await getCollection("pages");
11
- return pages.map((page) => ({
12
- params: { slug: page.id },
13
- props: { page },
14
- }));
11
+ return pages
12
+ .filter((page) => page.id !== "home")
13
+ .map((page) => ({
14
+ params: { slug: page.id },
15
+ props: { page },
16
+ }));
15
17
  }
16
18
 
17
19
  const { page } = Astro.props;
@@ -23,7 +25,6 @@ const refs = (page.data._refs || {}) as RefsMap;
23
25
  <BaseLayout title={page.data.title}>
24
26
  <article class="py-12 px-6">
25
27
  <div class="max-w-3xl mx-auto">
26
- <h1 class="text-4xl font-extrabold tracking-tight mb-8">{page.data.title}</h1>
27
28
  <div class="prose">
28
29
  <BlockRenderer blocks={blocks} refs={refs} resolveContentLink={resolveContentLink} />
29
30
  </div>
@@ -2,6 +2,9 @@
2
2
  import { getCollection, getEntry } from "astro:content";
3
3
  import BaseLayout from "../layouts/BaseLayout.astro";
4
4
  import PostCard from "../components/PostCard.astro";
5
+ import BlockRenderer from "@headroom-cms/api/blocks/BlockRenderer.astro";
6
+ import type { Block, RefsMap } from "@headroom-cms/api";
7
+ import { resolveContentLink } from "../lib/links";
5
8
 
6
9
  const settingsEntry = await getEntry("site-settings", "site-settings");
7
10
  const siteName = (settingsEntry?.data.body?.siteName as string) || "My Site";
@@ -10,6 +13,11 @@ const tagline = (settingsEntry?.data.body?.tagline as string) || "";
10
13
  const posts = (await getCollection("posts"))
11
14
  .sort((a, b) => new Date(b.data.publishedAt).getTime() - new Date(a.data.publishedAt).getTime())
12
15
  .slice(0, 3);
16
+
17
+ const pages = await getCollection("pages");
18
+ const homePage = pages.find((page) => page.id === "home");
19
+ const homeBlocks = (homePage?.data.body?.content || []) as Block[];
20
+ const homeRefs = (homePage?.data._refs || {}) as RefsMap;
13
21
  ---
14
22
 
15
23
  <BaseLayout>
@@ -34,4 +42,12 @@ const posts = (await getCollection("posts"))
34
42
  </div>
35
43
  </section>
36
44
  )}
45
+
46
+ {homeBlocks.length > 0 && (
47
+ <section class="py-12 px-6">
48
+ <div class="max-w-3xl mx-auto prose">
49
+ <BlockRenderer blocks={homeBlocks} refs={homeRefs} resolveContentLink={resolveContentLink} />
50
+ </div>
51
+ </section>
52
+ )}
37
53
  </BaseLayout>
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /* deno-fmt-ignore-file */
5
+ /* biome-ignore-all lint: auto-generated */
6
+
7
+ /// <reference path="../../../../sst-env.d.ts" />
8
+
9
+ import "sst"
10
+ export {}
@@ -0,0 +1,394 @@
1
+ # Headroom CMS - Next.js Site
2
+
3
+ This is a Next.js site powered by [Headroom CMS](https://github.com/headroom-cms), a serverless headless CMS. Content is managed in the Headroom admin UI and fetched via the `@headroom-cms/api` SDK.
4
+
5
+ ## Tech Stack
6
+
7
+ - **Next.js 15** (App Router, React Server Components)
8
+ - **@headroom-cms/api** SDK for data fetching and block rendering
9
+ - **Tailwind CSS v4** for styling
10
+
11
+ ## Project Structure
12
+
13
+ ```
14
+ src/
15
+ ├── app/
16
+ │ ├── layout.tsx # Root layout with Header, Footer, metadata from site-settings
17
+ │ ├── page.tsx # Homepage
18
+ │ ├── globals.css # Tailwind imports
19
+ │ ├── not-found.tsx # 404 page
20
+ │ ├── blog/
21
+ │ │ ├── page.tsx # Blog listing
22
+ │ │ └── [slug]/page.tsx # Blog post detail
23
+ │ └── [slug]/page.tsx # Dynamic pages from "pages" collection
24
+ ├── components/ # Reusable React components
25
+ └── lib/
26
+ ├── client.ts # Singleton HeadroomClient instance
27
+ └── links.ts # Content link resolver (maps refs to URL paths)
28
+ ```
29
+
30
+ ## Environment Variables
31
+
32
+ Copy `_env.example` to `.env.local` and fill in your Headroom credentials:
33
+
34
+ ```bash
35
+ HEADROOM_URL=https://your-headroom-url.cloudfront.net
36
+ HEADROOM_SITE=your-site.com
37
+ HEADROOM_API_KEY=headroom_xxxxx
38
+ HEADROOM_IMAGE_SIGNING_SECRET=your-secret # optional, for image transforms
39
+ ```
40
+
41
+ ## Commands
42
+
43
+ ```bash
44
+ npm run dev # Start Next.js dev server
45
+ npm run build # Build for production
46
+ npm run start # Start production server
47
+ npm run generate:schemas # Generate Zod schemas from Headroom collections
48
+ ```
49
+
50
+ ## Default Collections
51
+
52
+ New Headroom sites are scaffolded with three collections:
53
+
54
+ | Collection | Type | Description |
55
+ |------------|------|-------------|
56
+ | `posts` | Standard | Blog posts with `author` (text) and `content` (blocks) fields; use the `featured` tag to mark featured posts |
57
+ | `pages` | Standard | Generic pages with a `content` (blocks) field |
58
+ | `site-settings` | Singleton | Site name, tagline, footer text, and menu items |
59
+
60
+ ## Fetching Data
61
+
62
+ All data fetching uses the `HeadroomClient` from `src/lib/client.ts`. Since this is a Next.js App Router project, data fetching happens in **Server Components** (the default).
63
+
64
+ ### Getting the Client
65
+
66
+ ```tsx
67
+ import { getClient } from "@/lib/client";
68
+
69
+ export default async function MyPage() {
70
+ const client = getClient();
71
+ const { items: posts } = await client.listContent("posts", {
72
+ limit: 10,
73
+ sort: "published_desc",
74
+ });
75
+ // ...
76
+ }
77
+ ```
78
+
79
+ ### SDK Client Methods
80
+
81
+ | Method | Returns | Description |
82
+ |--------|---------|-------------|
83
+ | `listContent(collection, opts?)` | `{ items, cursor, hasMore }` | List published content with pagination/filtering |
84
+ | `getContent(contentId)` | `ContentItem` | Get single item by ID (includes body + refs) |
85
+ | `getContentBySlug(collection, slug)` | `ContentItem \| undefined` | Look up by slug |
86
+ | `getSingleton(collection)` | `ContentItem` | Get singleton content (e.g. site settings) |
87
+ | `listCollections()` | `{ items }` | List all collections |
88
+ | `getCollection(name)` | `Collection` | Get collection schema |
89
+ | `mediaUrl(path)` | `string` | Full URL for a media path |
90
+ | `transformUrl(path, opts?)` | `string` | Signed image transform URL |
91
+
92
+ ### listContent Options
93
+
94
+ ```ts
95
+ const { items, cursor, hasMore } = await client.listContent("posts", {
96
+ limit: 10,
97
+ cursor: "...", // pagination
98
+ sort: "published_desc", // published_desc | published_asc | title_asc | title_desc
99
+ before: 1700000000, // unix timestamp filter
100
+ after: 1690000000,
101
+ relatedTo: "01ABC", // reverse relationship query
102
+ relField: "author", // filter reverse query to specific field
103
+ });
104
+ ```
105
+
106
+ ### Static Generation
107
+
108
+ Use `generateStaticParams` to pre-render content pages:
109
+
110
+ ```tsx
111
+ export async function generateStaticParams() {
112
+ const client = getClient();
113
+ const { items } = await client.listContent("posts", { limit: 100 });
114
+ return items.map((post) => ({ slug: post.slug }));
115
+ }
116
+ ```
117
+
118
+ ### Fetching Singleton Content
119
+
120
+ Site-wide settings are stored in the `site-settings` singleton collection:
121
+
122
+ ```tsx
123
+ const client = getClient();
124
+ const settings = await client.getSingleton("site-settings").catch(() => null);
125
+ const siteName = (settings?.body?.siteName as string) || "My Site";
126
+ const menuItems = (settings?.body?.menuItems as Array<{ label: string; href: string }>) || [];
127
+ ```
128
+
129
+ ## Rendering Blocks
130
+
131
+ Content bodies are arrays of [BlockNote](https://www.blocknotejs.org/) blocks. Use the React `BlockRenderer` component:
132
+
133
+ ```tsx
134
+ import { BlockRenderer } from "@headroom-cms/api/react";
135
+ import "@headroom-cms/api/react/headroom-blocks.css";
136
+ import type { Block, RefsMap } from "@headroom-cms/api";
137
+ import { resolveContentLink } from "@/lib/links";
138
+
139
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
140
+ const { slug } = await params;
141
+ const client = getClient();
142
+ const post = await client.getContentBySlug("posts", slug);
143
+ if (!post) notFound();
144
+
145
+ const blocks = (post.body?.content || []) as Block[];
146
+ const refs = (post._refs || {}) as RefsMap;
147
+
148
+ return (
149
+ <div className="prose">
150
+ <BlockRenderer
151
+ blocks={blocks}
152
+ baseUrl={process.env.HEADROOM_URL}
153
+ refs={refs}
154
+ resolveContentLink={resolveContentLink}
155
+ />
156
+ </div>
157
+ );
158
+ }
159
+ ```
160
+
161
+ ### BlockRenderer Props
162
+
163
+ | Prop | Type | Description |
164
+ |------|------|-------------|
165
+ | `blocks` | `Block[]` | Block content array from `post.body.content` |
166
+ | `baseUrl` | `string?` | Base URL for media |
167
+ | `refs` | `RefsMap?` | Content reference map from `post._refs` (resolves `headroom://` links) |
168
+ | `resolveContentLink` | `(ref: PublicContentRef) => string` | Maps content references to URL paths |
169
+ | `components` | `BlockComponentMap?` | Override or extend block component rendering |
170
+ | `fallback` | `ComponentType \| null` | Custom fallback for unknown blocks (`null` to suppress) |
171
+ | `className` | `string?` | CSS class for the wrapper `<div>` |
172
+
173
+ ### Block Types
174
+
175
+ Built-in block types: `paragraph`, `heading`, `image`, `codeBlock`, `bulletListItem`, `numberedListItem`, `checkListItem`, `table`
176
+
177
+ ### Styling Blocks
178
+
179
+ Import the default stylesheet:
180
+
181
+ ```tsx
182
+ import "@headroom-cms/api/react/headroom-blocks.css";
183
+ ```
184
+
185
+ Styles use low-specificity `:where()` selectors, easy to override. Customize via CSS custom properties:
186
+
187
+ | Property | Default | Used by |
188
+ |----------|---------|---------|
189
+ | `--hr-code-bg` | `#f3f4f6` | Inline code background |
190
+ | `--hr-link-color` | `#2563eb` | Link color |
191
+ | `--hr-image-radius` | `0.5rem` | Image border radius |
192
+ | `--hr-caption-color` | `#6b7280` | Image caption color |
193
+ | `--hr-code-block-bg` | `#1e1e1e` | Code block background |
194
+ | `--hr-code-block-color` | `#d4d4d4` | Code block text color |
195
+ | `--hr-accent` | `#2563eb` | Checkbox accent color |
196
+ | `--hr-table-header-bg` | `#f9fafb` | Table header background |
197
+
198
+ ### Custom Block Components
199
+
200
+ Pass a `components` map to override built-in blocks or render custom block types:
201
+
202
+ ```tsx
203
+ import { BlockRenderer } from "@headroom-cms/api/react";
204
+ import type { BlockComponentProps } from "@headroom-cms/api/react";
205
+
206
+ function Callout({ block }: BlockComponentProps) {
207
+ return (
208
+ <div className="bg-blue-50 border-l-4 border-blue-500 p-4 my-4">
209
+ <p>{block.props?.text as string}</p>
210
+ </div>
211
+ );
212
+ }
213
+
214
+ function CustomImage({ block }: BlockComponentProps) {
215
+ const src = block.props?.url as string;
216
+ return (
217
+ <figure className="my-8">
218
+ <img src={src} alt={block.props?.caption as string} className="rounded-xl shadow-lg" />
219
+ </figure>
220
+ );
221
+ }
222
+
223
+ <BlockRenderer
224
+ blocks={blocks}
225
+ baseUrl={process.env.HEADROOM_URL}
226
+ refs={refs}
227
+ resolveContentLink={resolveContentLink}
228
+ components={{
229
+ callout: Callout,
230
+ image: CustomImage, // override built-in image rendering
231
+ }}
232
+ />
233
+ ```
234
+
235
+ ## Content Links
236
+
237
+ Rich text can contain links to other Headroom content using `headroom://content/{collection}/{contentId}` URLs. The `_refs` map on each content item resolves these to metadata (slug, title, collection, published status).
238
+
239
+ The `BlockRenderer` resolves these automatically using the `resolveContentLink` prop. Define your URL mapping in `src/lib/links.ts`:
240
+
241
+ ```ts
242
+ import type { PublicContentRef } from "@headroom-cms/api";
243
+
244
+ export function resolveContentLink(ref: PublicContentRef): string {
245
+ switch (ref.collection) {
246
+ case "posts":
247
+ return `/blog/${ref.slug}`;
248
+ default:
249
+ return `/${ref.slug}`;
250
+ }
251
+ }
252
+ ```
253
+
254
+ ## Relationships
255
+
256
+ Collections can define relationships to other collections. Access them on content items:
257
+
258
+ ```ts
259
+ // Forward: get a project's related artists
260
+ const project = await client.getContent("01ABC");
261
+ const artists = project.relationships?.artists; // ContentRef[]
262
+
263
+ // Reverse: find all projects referencing an artist
264
+ const { items } = await client.listContent("projects", {
265
+ relatedTo: "01ARTIST",
266
+ relField: "artists",
267
+ });
268
+ ```
269
+
270
+ ## Images and Media
271
+
272
+ Media paths in content are relative (e.g., `/media/site/id/original.jpg`). Use the client to build full URLs:
273
+
274
+ ```ts
275
+ const client = getClient();
276
+
277
+ // Full URL for original image
278
+ client.mediaUrl(post.coverUrl);
279
+
280
+ // Signed transform URL (resized + webp)
281
+ client.transformUrl(post.coverUrl, { width: 800, format: "webp" });
282
+ ```
283
+
284
+ ### Transform Options
285
+
286
+ | Option | Type | Description |
287
+ |--------|------|-------------|
288
+ | `width` | `number` | Target width in pixels |
289
+ | `height` | `number` | Target height in pixels |
290
+ | `fit` | `"cover" \| "contain" \| "fill" \| "inside" \| "outside"` | Resize fit mode |
291
+ | `format` | `"webp" \| "avif" \| "jpeg" \| "png"` | Output format |
292
+ | `quality` | `number` | 1-100 quality |
293
+
294
+ Transforms require `HEADROOM_IMAGE_SIGNING_SECRET` in `.env.local`. Without it, `transformUrl()` falls back to `mediaUrl()`.
295
+
296
+ ## Adding a New Collection
297
+
298
+ 1. Create the collection in the Headroom admin UI
299
+ 2. Create pages under `src/app/`:
300
+ ```tsx
301
+ // src/app/projects/page.tsx — listing
302
+ import { getClient } from "@/lib/client";
303
+
304
+ export default async function ProjectsPage() {
305
+ const client = getClient();
306
+ const { items } = await client.listContent("projects", { sort: "published_desc" });
307
+ return (
308
+ <div className="max-w-5xl mx-auto py-12 px-6">
309
+ {items.map((project) => (
310
+ <a key={project.contentId} href={`/projects/${project.slug}`}>
311
+ <h3>{project.title}</h3>
312
+ </a>
313
+ ))}
314
+ </div>
315
+ );
316
+ }
317
+ ```
318
+ ```tsx
319
+ // src/app/projects/[slug]/page.tsx — detail
320
+ import { getClient } from "@/lib/client";
321
+ import { BlockRenderer } from "@headroom-cms/api/react";
322
+ import "@headroom-cms/api/react/headroom-blocks.css";
323
+ import { resolveContentLink } from "@/lib/links";
324
+ import { notFound } from "next/navigation";
325
+ import type { Block, RefsMap } from "@headroom-cms/api";
326
+
327
+ export async function generateStaticParams() {
328
+ const client = getClient();
329
+ const { items } = await client.listContent("projects", { limit: 100 });
330
+ return items.map((p) => ({ slug: p.slug }));
331
+ }
332
+
333
+ export default async function ProjectPage({ params }: { params: Promise<{ slug: string }> }) {
334
+ const { slug } = await params;
335
+ const client = getClient();
336
+ const project = await client.getContentBySlug("projects", slug);
337
+ if (!project) notFound();
338
+
339
+ const blocks = (project.body?.content || []) as Block[];
340
+ const refs = (project._refs || {}) as RefsMap;
341
+
342
+ return (
343
+ <article className="py-12 px-6">
344
+ <div className="max-w-3xl mx-auto">
345
+ <h1 className="text-4xl font-extrabold tracking-tight mb-4">{project.title}</h1>
346
+ <div className="prose">
347
+ <BlockRenderer blocks={blocks} baseUrl={process.env.HEADROOM_URL} refs={refs} resolveContentLink={resolveContentLink} />
348
+ </div>
349
+ </div>
350
+ </article>
351
+ );
352
+ }
353
+ ```
354
+ 3. Update `src/lib/links.ts` to map the new collection to URL paths
355
+
356
+ ## Error Handling
357
+
358
+ ```ts
359
+ import { HeadroomClient, HeadroomError } from "@headroom-cms/api";
360
+
361
+ try {
362
+ const post = await client.getContent("nonexistent");
363
+ } catch (e) {
364
+ if (e instanceof HeadroomError) {
365
+ console.log(e.status); // 404
366
+ console.log(e.code); // "CONTENT_NOT_FOUND"
367
+ }
368
+ }
369
+ ```
370
+
371
+ ## Key Types
372
+
373
+ ```ts
374
+ import type {
375
+ ContentItem, // Full content item with body, refs, relationships
376
+ ContentMetadata, // Content without body (listings)
377
+ Block, // A BlockNote block
378
+ RefsMap, // Map of content ID -> PublicContentRef
379
+ PublicContentRef, // { contentId, collection, slug, title, published }
380
+ ContentRef, // Relationship reference
381
+ Collection, // Collection schema
382
+ FieldDef, // Field definition in a collection
383
+ RelationshipDef, // Relationship definition
384
+ HeadroomConfig, // Client configuration
385
+ TransformOptions, // Image transform options
386
+ } from "@headroom-cms/api";
387
+
388
+ // React-specific types
389
+ import type {
390
+ BlockRendererProps,
391
+ BlockComponentProps,
392
+ BlockComponentMap,
393
+ } from "@headroom-cms/api/react";
394
+ ```
@@ -5,17 +5,19 @@
5
5
  "scripts": {
6
6
  "dev": "next dev",
7
7
  "build": "next build",
8
- "start": "next start"
8
+ "start": "next start",
9
+ "generate:schemas": "bash scripts/generate-schemas.sh"
9
10
  },
10
11
  "dependencies": {
11
12
  "next": "^15.0.0",
12
13
  "react": "^19.0.0",
13
14
  "react-dom": "^19.0.0",
14
- "@headroom-cms/api": "^0.1.3"
15
+ "@headroom-cms/api": "^0.1.4"
15
16
  },
16
17
  "devDependencies": {
17
18
  "@tailwindcss/postcss": "^4.0.0",
18
19
  "tailwindcss": "^4.0.0",
20
+ "tsx": "^4.0.0",
19
21
  "typescript": "^5.9.3",
20
22
  "@types/react": "^19.0.0",
21
23
  "@types/node": "^22.0.0"
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Generates Zod schemas from Headroom collection definitions.
5
+ # Requires: .env with HEADROOM_* vars, API accessible.
6
+
7
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
8
+ SITE_DIR="$(dirname "$SCRIPT_DIR")"
9
+
10
+ if [ ! -f "$SITE_DIR/.env" ]; then
11
+ echo "Error: .env not found. Create a .env file with HEADROOM_URL, HEADROOM_SITE, and HEADROOM_API_KEY." >&2
12
+ exit 1
13
+ fi
14
+
15
+ cd "$SITE_DIR"
16
+ npx tsx scripts/generate-schemas.ts
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * Generates Zod schemas from Headroom collection definitions.
4
+ * Reads connection details from .env and writes src/lib/schemas.ts.
5
+ *
6
+ * Usage: npx tsx scripts/generate-schemas.ts
7
+ */
8
+
9
+ import { writeFileSync } from "node:fs";
10
+ import { resolve, dirname } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { HeadroomClient } from "@headroom-cms/api";
13
+ import { generateZodSchemas } from "@headroom-cms/api/codegen";
14
+ import { configFromEnv } from "@headroom-cms/api/astro";
15
+
16
+ const __dirname = dirname(fileURLToPath(import.meta.url));
17
+ const siteDir = resolve(__dirname, "..");
18
+
19
+ const config = configFromEnv();
20
+ if (!config.site || !config.apiKey) {
21
+ console.error("Error: HEADROOM_SITE and HEADROOM_API_KEY must be set in .env");
22
+ process.exit(1);
23
+ }
24
+
25
+ const client = new HeadroomClient(config);
26
+ const output = await generateZodSchemas(client);
27
+
28
+ const outPath = resolve(siteDir, "src/lib/schemas.ts");
29
+ writeFileSync(outPath, output, "utf-8");
30
+ console.log(`Schemas generated at src/lib/schemas.ts`);
@@ -12,7 +12,9 @@ interface Props {
12
12
  export async function generateStaticParams() {
13
13
  const client = getClient();
14
14
  const { items } = await client.listContent("pages", { limit: 100 });
15
- return items.map((page) => ({ slug: page.slug }));
15
+ return items
16
+ .filter((page) => page.slug !== "home")
17
+ .map((page) => ({ slug: page.slug }));
16
18
  }
17
19
 
18
20
  export default async function Page({ params }: Props) {
@@ -27,9 +29,6 @@ export default async function Page({ params }: Props) {
27
29
  return (
28
30
  <article className="py-12 px-6">
29
31
  <div className="max-w-3xl mx-auto">
30
- <h1 className="text-4xl font-extrabold tracking-tight mb-8">
31
- {page.title}
32
- </h1>
33
32
  <div className="prose">
34
33
  <BlockRenderer
35
34
  blocks={blocks}
@@ -1,5 +1,9 @@
1
1
  import { getClient } from "@/lib/client";
2
2
  import { PostCard } from "@/components/PostCard";
3
+ import { BlockRenderer } from "@headroom-cms/api/react";
4
+ import "@headroom-cms/api/react/headroom-blocks.css";
5
+ import { resolveContentLink } from "@/lib/links";
6
+ import type { Block, RefsMap } from "@headroom-cms/api";
3
7
 
4
8
  export default async function Home() {
5
9
  const client = getClient();
@@ -8,6 +12,9 @@ export default async function Home() {
8
12
  limit: 3,
9
13
  sort: "published_desc",
10
14
  });
15
+ const homePage = await client.getContentBySlug("pages", "home").catch(() => null);
16
+ const homeBlocks = (homePage?.body?.content || []) as Block[];
17
+ const homeRefs = (homePage?._refs || {}) as RefsMap;
11
18
 
12
19
  return (
13
20
  <>
@@ -36,6 +43,19 @@ export default async function Home() {
36
43
  </div>
37
44
  </section>
38
45
  )}
46
+
47
+ {homeBlocks.length > 0 && (
48
+ <section className="py-12 px-6">
49
+ <div className="max-w-3xl mx-auto prose">
50
+ <BlockRenderer
51
+ blocks={homeBlocks}
52
+ baseUrl={process.env.HEADROOM_URL}
53
+ refs={homeRefs}
54
+ resolveContentLink={resolveContentLink}
55
+ />
56
+ </div>
57
+ </section>
58
+ )}
39
59
  </>
40
60
  );
41
61
  }
@@ -1,7 +1,14 @@
1
- export function Footer() {
1
+ import { getClient } from "@/lib/client";
2
+
3
+ export async function Footer() {
4
+ const client = getClient();
5
+ const settings = await client.getSingleton("site-settings").catch(() => null);
6
+ const footerText = (settings?.body?.footerText as string) || "";
7
+
2
8
  return (
3
9
  <footer className="border-t border-gray-100 py-8 mt-16">
4
10
  <div className="max-w-5xl mx-auto px-6 text-center text-sm text-gray-500">
11
+ {footerText && <p className="mb-2">{footerText}</p>}
5
12
  <p>
6
13
  Powered by{" "}
7
14
  <a href="https://headroom.dev" className="hover:underline">Headroom CMS</a>
@@ -5,6 +5,7 @@ export async function Header() {
5
5
  const client = getClient();
6
6
  const settings = await client.getSingleton("site-settings").catch(() => null);
7
7
  const siteName = (settings?.body?.siteName as string) || "My Site";
8
+ const menuItems = (settings?.body?.menuItems as Array<{ label: string; href: string }>) || [];
8
9
 
9
10
  return (
10
11
  <header className="border-b border-gray-100 bg-white sticky top-0 z-50">
@@ -12,10 +13,13 @@ export async function Header() {
12
13
  <Link href="/" className="text-xl font-bold tracking-tight hover:opacity-80 transition-opacity">
13
14
  {siteName}
14
15
  </Link>
15
- <ul className="flex items-center gap-6">
16
- <li><Link href="/" className="text-sm font-medium text-gray-600 hover:text-gray-900">Home</Link></li>
17
- <li><Link href="/blog" className="text-sm font-medium text-gray-600 hover:text-gray-900">Blog</Link></li>
18
- </ul>
16
+ {menuItems.length > 0 && (
17
+ <ul className="flex items-center gap-6">
18
+ {menuItems.map((item) => (
19
+ <li key={item.href}><Link href={item.href} className="text-sm font-medium text-gray-600 hover:text-gray-900">{item.label}</Link></li>
20
+ ))}
21
+ </ul>
22
+ )}
19
23
  </nav>
20
24
  </header>
21
25
  );
@@ -0,0 +1,10 @@
1
+ /* This file is auto-generated by SST. Do not edit. */
2
+ /* tslint:disable */
3
+ /* eslint-disable */
4
+ /* deno-fmt-ignore-file */
5
+ /* biome-ignore-all lint: auto-generated */
6
+
7
+ /// <reference path="../../../../sst-env.d.ts" />
8
+
9
+ import "sst"
10
+ export {}