# Blog Setup Guide for Lyra (Next.js App Router)

Hand this whole file to Claude (or any coding agent, such as Codex) inside your repo and say:
**"Build my blog route following this guide."** It describes the exact blog
architecture Lyra writes against, modeled on the production blog at trylyra.ai.
Once this structure exists in your repo, Lyra can discover topics, write posts in
your voice, generate matching banners, and open pull requests you merge.

This guide targets Next.js (App Router). If your site is on Astro, Hugo, or
Jekyll, the frontmatter contract and folder model are the same; tell your agent to
target your framework's static-generation equivalent instead of the Next.js
routes below.

---

## 0. What Lyra expects to find

Lyra writes a new post by **dropping one markdown file into a content folder** and
opening a PR. For that to work, your repo needs:

1. A content folder of markdown posts with YAML frontmatter: `content/blog/*.md`.
2. A build-time reader that parses those files: `lib/blog.server.ts`.
3. A blog index route: `app/blog/page.tsx`.
4. A blog post route that statically generates each slug: `app/blog/[slug]/page.tsx`.
5. A folder of hero banner images: `public/blog/<slug>.webp`.
6. Shared types: `lib/blog-types.ts`.

The slug is the filename. `serverless-inference-explained.md` becomes
`/blog/serverless-inference-explained/`. Adding a post is adding a file, then
rebuilding. No database, no CMS.

> **Critical:** before Lyra writes anything, seed this folder with **3 to 5 of
> your own existing posts** (real ones, in your brand voice). Lyra reads them to
> learn your tone, structure, formatting, and banner style, then writes new posts
> that match but are tuned harder for SEO and AI citations. Details in section 7.

---

## 1. Directory structure to create

```
app/
  blog/
    page.tsx              # blog index (server component)
    [slug]/page.tsx       # blog post (static, generateStaticParams + JSON-LD)
components/
  blog/
    BlogContent.tsx       # markdown renderer (or use your existing one)
    BlogCard.tsx          # listing card
lib/
  site.ts                 # site config: name, url, contact path
  blog-types.ts           # blog types + labels (client-safe)
  blog.server.ts          # filesystem reader + frontmatter parser (build-time only)
content/
  blog/                   # markdown posts, one file = one post
public/
  blog/                   # hero banners, <slug>.webp
```

`blog.server.ts` touches the filesystem, so it is **server only**. Never import
it into a client component. Types and labels live in `blog-types.ts`, which is
safe to import anywhere.

---

## 2. Frontmatter schema (the contract)

Every post is a `.md` file that starts with a YAML block. This is the exact shape
Lyra reads and writes:

```yaml
---
title: "Your Post Title: include the primary keyword near the front"
excerpt: "150 to 160 char description used on cards and as the meta description. Include the target keyword."
date: "2026-06-28"
authorInfo:
  name: "Author Name"
  title: "Role"
  url: "https://www.linkedin.com/in/your-handle/"
image: "/blog/your-post-slug.webp"
type: "tutorial"
featured: 0
tags:
  - Primary Keyword Phrase
  - Secondary Tag
faqs:
  - question: "A question people actually ask about this topic?"
    answer: "A concise answer that can surface as a rich snippet or an AI citation."
howToSteps:
  - name: "Step title"
    text: "What to do in this step."
---

Body of the post in markdown starts here.
```

**Required:** `title`, `excerpt`, `date`, `image`, `type`.
**Optional:** `authorInfo`, `featured`, `tags`, `faqs`, `howToSteps`.

Notes for the parser: quote `title` and `excerpt` (they usually contain colons).
Keep tag values colon-free. List items are indented two spaces under their key.

### Valid `type` values

`alternatives`, `comparison`, `case-study`, `engineering`, `product`,
`tutorial`, `company`, `research`. Pick the one that fits; it shows as the
category eyebrow and drives the "related posts" grouping.

### Featured posts

`featured: 0` (default) means not featured. `featured: 1` is the top slot, `2`
and `3` follow. Up to 3 render in the index Featured row, sorted ascending.

---

## 3. Shared types: `lib/blog-types.ts`

Client-safe. No filesystem access. Create it like this:

```ts
export type BlogType =
  | "alternatives" | "comparison" | "case-study" | "engineering"
  | "product" | "tutorial" | "company" | "research";

export interface BlogFAQ { question: string; answer: string; }
export interface BlogHowToStep { name: string; text: string; }
export interface BlogAuthor { name: string; title?: string; url?: string; }

export interface BlogPost {
  slug: string;
  title: string;
  excerpt: string;
  content: string;
  date: string;        // formatted for display, e.g. "Jun 28, 2026"
  rawDate: string;     // original ISO date from frontmatter
  author?: string;
  authorInfo?: BlogAuthor;
  image: string;
  type: BlogType;
  featured: number;    // 0 = not featured, 1+ = featured order
  tags?: string[];
  faqs?: BlogFAQ[];
  howToSteps?: BlogHowToStep[];
  readingMinutes: number;
}

export interface BlogCardPost {
  slug: string; title: string; excerpt: string; image: string;
  date: string; type: BlogType; featured: number; readingMinutes: number;
}

export const BLOG_TYPE_LABELS: Record<BlogType | "all", string> = {
  all: "All", alternatives: "Alternatives", comparison: "Comparison",
  "case-study": "Case Study", engineering: "Engineering", product: "Product",
  tutorial: "Tutorial", company: "Company", research: "Research",
};
```

---

## 4. Build-time reader: `lib/blog.server.ts`

This reads `content/blog/*.md` at build time and parses the frontmatter. The
reference site keeps it **dependency-free** with a small YAML-subset parser, so
there are no build surprises. If you would rather not maintain a parser, you can
swap in `gray-matter` (`npm i gray-matter`) and keep the same public functions.
Either way, expose these functions:

```ts
import fs from "fs";
import path from "path";
import { BlogPost, BlogType, BlogCardPost } from "./blog-types";

const BLOG_DIRECTORY = path.join(process.cwd(), "content", "blog");

// --- helpers ---
function formatDate(d: string): string {
  if (!d) return "";
  const date = new Date(d);
  return isNaN(date.getTime())
    ? d
    : date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function estimateReadingMinutes(content: string): number {
  const words = content.trim().split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.round(words / 200));
}

// parseFrontmatter(raw) -> { data, content }
// Either hand-roll a small parser for the subset above (scalars, string lists,
// a nested authorInfo map, and lists of maps for faqs / howToSteps), OR use
// gray-matter. The frontmatter subset is intentionally simple.

export function getAllBlogSlugs(): string[] {
  if (!fs.existsSync(BLOG_DIRECTORY)) return [];
  return fs.readdirSync(BLOG_DIRECTORY)
    .filter((f) => (f.endsWith(".md") || f.endsWith(".mdx")) && f.toLowerCase() !== "readme.md")
    .map((f) => f.replace(/\.mdx?$/, ""));
}

export function getBlogPostBySlug(slug: string): BlogPost | null { /* read file, parse, map fields */ }
export function getAllBlogPosts(): BlogPost[] { /* map slugs -> posts, sort by date desc */ }
export function getAllBlogCards(): BlogCardPost[] { /* drop the heavy `content` field */ }
export function getFeaturedPosts(): BlogCardPost[] { /* featured > 0, sorted asc, max 3 */ }
export function getRelatedPosts(slug: string, type: BlogType, limit = 3): BlogCardPost[] { /* same type first, then recent */ }
```

The mapping inside `getBlogPostBySlug` should:

- default `title` to the slug, `excerpt`/`content`/`image` to empty strings,
- default `type` to `engineering` if missing,
- treat `featured: true` as a high number and a missing/`false` as `0`,
- always return `tags` as an array,
- compute `readingMinutes` from word count.

> Ask Claude to "implement `lib/blog.server.ts` reading `content/blog/*.md` with
> the frontmatter fields in this guide, dependency-free or with gray-matter,
> exposing exactly these functions." It will fill in the bodies correctly.

---

## 5. Routes

### `app/blog/[slug]/page.tsx` (the post page)

This is the important one. It must:

1. `export const dynamicParams = false;`
2. `export function generateStaticParams()` returning every slug, so each post is
   statically generated and unknown slugs 404.
3. `export async function generateMetadata({ params })` that sets the title,
   description (from `excerpt`), `keywords` (from `tags.join(", ")`), a
   self-referencing canonical URL ending in `/`, and OpenGraph/Twitter tags whose
   image is the post banner.
4. Render the post: category eyebrow, `<h1>` title, excerpt, byline + date + read
   time, the hero `<Image>`, the markdown body, optional HowTo and FAQ sections,
   an end-of-post CTA, tags, and a "related posts" grid.
5. Emit JSON-LD `<script type="application/ld+json">` blocks for `BlogPosting`,
   `BreadcrumbList`, and `FAQPage` (only when the post has FAQs). Add `HowTo`
   when `howToSteps` exist.

In Next 15, `params` is a **Promise**. Always `await params` before using
`slug`.

Skeleton:

```tsx
import { notFound } from "next/navigation";
import { getAllBlogSlugs, getBlogPostBySlug, getRelatedPosts } from "@/lib/blog.server";
import { site } from "@/lib/site";

export const dynamicParams = false;
export function generateStaticParams() {
  return getAllBlogSlugs().map((slug) => ({ slug }));
}

export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getBlogPostBySlug(slug);
  if (!post) return { title: `Post not found | ${site.name}` };
  const url = `${site.url}/blog/${post.slug}/`;
  const image = post.image ? `${site.url}${post.image}` : `${site.url}/og-home.webp`;
  return {
    title: post.title,
    description: post.excerpt,
    keywords: post.tags?.length ? post.tags.join(", ") : undefined,
    alternates: { canonical: url },
    openGraph: { type: "article", title: post.title, description: post.excerpt, url, images: [{ url: image, width: 1920, height: 1080 }] },
    twitter: { card: "summary_large_image", title: post.title, description: post.excerpt, images: [image] },
  };
}

export default async function BlogPostPage({ params }: { params: Promise<{ slug: string }> }) {
  const { slug } = await params;
  const post = getBlogPostBySlug(slug);
  if (!post) notFound();
  const related = getRelatedPosts(post.slug, post.type, 3);
  // ... render masthead, hero image, <BlogContent content={post.content} />,
  //     FAQ/HowTo, CTA, tags, related grid, plus the JSON-LD scripts.
}
```

### `app/blog/page.tsx` (the index)

A server component that calls `getFeaturedPosts()` and `getAllBlogCards()`,
renders a masthead, a Featured row (if any), and a Latest grid of `BlogCard`s.
Set its own `metadata` with a canonical of `${site.url}/blog/`.

### `lib/site.ts` (config)

One object holding the values the routes reference:

```ts
export const site = {
  name: "Your Company",
  url: (process.env.NEXT_PUBLIC_SITE_URL || "https://example.com").replace(/\/+$/, ""),
  // Where blog CTAs point (your contact/signup/console URL):
  contactPath: "/contact/",
};
```

Keep `url` with **no trailing slash**; every caller appends a slashed path.

---

## 6. Markdown the renderer must support

The reference renderer (`BlogContent.tsx`) is a small, dependency-free component.
It supports: `## H2` and `### H3` (with auto-generated ids), paragraphs,
`**bold**`, `*italic*`, `` `inline code` ``, fenced code blocks with a copy
button, `[links](url)` (external links open in a new tab), markdown tables,
ordered and unordered lists (one nesting level), horizontal rules `---`,
standalone images, and blockquotes (styled as a callout, used for the end-of-post
CTA). You can reuse your existing markdown renderer instead, as long as it
handles this set. If you prefer a library, `react-markdown` with `remark-gfm`
covers all of it.

---

## 7. Seed Lyra with 3 to 5 existing posts (do this first)

Lyra learns your blog by **reading the posts already in `content/blog/`**. Before
you connect the repo, add **at least 3, ideally 5, of your real published posts**
as markdown files using the frontmatter schema above. This is what lets Lyra:

- **Match your voice.** It picks up sentence rhythm, vocabulary, how technical you
  go, how you open and close a post, and how you use headings. New posts read like
  your team wrote them, not like generic AI copy.
- **Match your structure.** Intro length, use of FAQs and HowTo steps, table and
  code-block habits, internal-linking patterns.
- **Then improve on it.** Lyra keeps your voice but tightens every post for SEO
  (one clear primary keyword, question-style headings, tight excerpt as meta
  description) and for **AI citation** (answer the question in the first line under
  each heading, cite verifiable dated facts, clean FAQ blocks that answer engines
  lift directly).

If you only have posts on the old site, port 3 to 5 of the best ones into the new
markdown format first. Quality over quantity: pick posts you are happy to have
Lyra imitate.

---

## 8. Banners: keep them simple so Lyra can match them

Every post needs a hero banner at `public/blog/<slug>.webp`, referenced in
frontmatter as `image: "/blog/<slug>.webp"`. Lyra generates a banner for each new
post by **looking at your existing banners and copying their design language**, so
the whole blog stays visually consistent.

For that to work, your seed banners need to be **simple and machine-readable**.
Follow these rules for the banners you ship with the seed posts:

- **16:9, one consistent size** (1920x1080 works well). Same dimensions on every
  banner.
- **Few elements.** A title, a small category label, your logo/wordmark, and at
  most one simple icon or shape. No busy collages, no photographs packed with
  detail, no five competing focal points.
- **One consistent color theme.** Pick your brand background, one accent color,
  and a text color, and use the same three on every banner. Lyra reads the palette
  off your existing banners and reuses it, so a consistent theme in equals a
  consistent theme out.
- **High contrast, readable text.** The title should be legible at a glance.
  Clean type on a calm background, not text over a noisy image.
- **Consistent layout.** Title in the same place, logo in the same corner, icon in
  the same zone, on every banner. Consistency is what Lyra pattern-matches.

The reference blog (trylyra.ai) does exactly this: a matte dark background, a
single coral accent, near-white text, a small wordmark in one corner, a short
category eyebrow, the title, and one minimal line icon per post. Simple, on-brand,
and easy for a model to reproduce. Do the same with your own brand colors.

If a banner is too busy, Lyra's generated versions will look "off" next to it.
Simple, consistent seed banners are the single biggest lever on banner quality.

---

## 9. SEO and AEO rules baked into the system

These are automatic once the structure above exists, but worth knowing:

- **Tags are your meta keywords.** `app/blog/[slug]/page.tsx` builds
  `<meta keywords>` from `tags.join(", ")`. Put the exact primary keyword phrase
  as the **first tag**. Use 4 to 7 tags. Do not let two posts fight over the same
  primary tag.
- **Title** under ~60 chars, primary keyword near the front.
- **Excerpt** is the meta description: 150 to 160 chars, include the keyword.
- **FAQs** generate `FAQPage` JSON-LD (the "People Also Ask" shape) and are the
  exact format AI answer engines cite. Include 2 to 5 per post.
- **Headers** should be the questions people actually ask.
- **Internal links:** every post links to 2 to 4 related posts and to a
  conversion page (contact/signup), with descriptive anchor text, using relative
  trailing-slashed paths like `/blog/another-post/`. Place links early.
- **Trailing slashes** on every internal link, and self-referencing canonicals.
  Set `trailingSlash: true` in `next.config` so the router serves the slashed URLs
  directly instead of redirecting.

---

## 10. Writing voice (applies to all generated content)

- No em dashes. Use a comma, period, or colon. Hyphens are fine.
- No AI-tell phrases: "delve into", "in the realm of", "it's worth noting",
  "game-changer", "leverage" as a verb, "seamlessly", "robust", "cutting-edge",
  "unlock the potential", "revolutionize", "in today's fast-paced world".
- Plain, direct, specific. Vary sentence length. Get to the point in the first
  line. Lyra will follow whatever voice your seed posts establish, so make the
  seed posts read the way you want every future post to read.

---

## 11. Build and verify

```bash
npm install        # or bun / pnpm
npm run dev        # http://localhost:3000/blog
npm run build      # statically generates every post
```

Checklist after building:

- `/blog/` lists your seed posts.
- `/blog/<slug>/` renders each post with its banner.
- View source on a post: the `BlogPosting`, `BreadcrumbList`, and (if FAQs)
  `FAQPage` JSON-LD blocks are present.
- The canonical tag matches the page URL and ends in `/`.
- An unknown slug like `/blog/does-not-exist/` returns 404.

---

## Pages Router note (only if your site still uses `pages/`)

If part of your site is on the Pages Router, the contract is identical, only the
plumbing changes:

- `pages/blog/index.tsx` with `getStaticProps` to list posts.
- `pages/blog/[slug].tsx` with `getStaticPaths` (return every slug,
  `fallback: false`) and `getStaticProps` to load one post.
- Use `next/head` for title, description, canonical, and the JSON-LD scripts
  instead of `generateMetadata`.

The content folder, frontmatter schema, reader functions, banners, and all the
SEO/AEO/voice rules stay exactly the same. App Router is recommended for new work.

---

**That is the whole blog system.** Once it builds and your 3 to 5 seed posts are
in place with simple, consistent banners, connect the repo to Lyra and she takes
it from there: discover a topic, write the post in your voice, generate a matching
banner, and open a pull request for you to review and merge.
