Logo VelaStack

Enable blog

Add an mdsvex-powered blog to your SvelteKit app. Scaffolds a /blog index with post cards, individual post pages, tag-based filtering, an RSS feed, and an AuthorChip component, along with a handful of example posts so the layout is populated out of the box. Post metadata is typed through a $lib/content module and rendered with a reusable PostCard, giving you a publish-ready content surface without hand-wiring markdown, routing, or feeds.

$ vela enable blog
src/css/prism-vsc-dark-plus.css
pre[class*='language-'],
code[class*='language-'] {
	color: #d4d4d4;
	font-size: 13px;
	text-shadow: none;
	font-family: Menlo, Monaco, Consolas, 'Andale Mono', 'Ubuntu Mono', 'Courier New', monospace;
	direction: ltr;
	text-align: left;
	white-space: pre;
	word-spacing: normal;
	word-break: normal;
	line-height: 1.5;
	-moz-tab-size: 4;
	-o-tab-size: 4;
	tab-size: 4;
	-webkit-hyphens: none;
	-moz-hyphens: none;
	-ms-hyphens: none;
	hyphens: none;
}

pre[class*='language-']::selection,
code[class*='language-']::selection,
pre[class*='language-'] *::selection,
code[class*='language-'] *::selection {
	text-shadow: none;
	background: #264f78;
}

@media print {
	pre[class*='language-'],
	code[class*='language-'] {
		text-shadow: none;
	}
}

pre[class*='language-'] {
	padding: 1em;
	margin: 0.5em 0;
	overflow: auto;
	background: oklch(0.21 0.034 264.665);
}

:not(pre) > code[class*='language-'] {
	padding: 0.1em 0.3em;
	border-radius: 0.3em;
	color: #db4c69;
	background: oklch(0.21 0.034 264.665);
}
/*********************************************************
* Tokens
*/
.namespace {
	opacity: 0.7;
}

.token.doctype .token.doctype-tag {
	color: #569cd6;
}

.token.doctype .token.name {
	color: #9cdcfe;
}

.token.comment,
.token.prolog {
	color: #6a9955;
}

.token.punctuation,
.language-html .language-css .token.punctuation,
.language-html .language-javascript .token.punctuation {
	color: #d4d4d4;
}

.token.property,
.token.tag,
.token.boolean,
.token.number,
.token.constant,
.token.symbol,
.token.inserted,
.token.unit {
	color: #b5cea8;
}

.token.selector,
.token.attr-name,
.token.string,
.token.char,
.token.builtin,
.token.deleted {
	color: #ce9178;
}

.language-css .token.string.url {
	text-decoration: underline;
}

.token.operator,
.token.entity {
	color: #d4d4d4;
}

.token.operator.arrow {
	color: #569cd6;
}

.token.atrule {
	color: #ce9178;
}

.token.atrule .token.rule {
	color: #c586c0;
}

.token.atrule .token.url {
	color: #9cdcfe;
}

.token.atrule .token.url .token.function {
	color: #dcdcaa;
}

.token.atrule .token.url .token.punctuation {
	color: #d4d4d4;
}

.token.keyword {
	color: #569cd6;
}

.token.keyword.module,
.token.keyword.control-flow {
	color: #c586c0;
}

.token.function,
.token.function .token.maybe-class-name {
	color: #dcdcaa;
}

.token.regex {
	color: #d16969;
}

.token.important {
	color: #569cd6;
}

.token.italic {
	font-style: italic;
}

.token.constant {
	color: #9cdcfe;
}

.token.class-name,
.token.maybe-class-name {
	color: #4ec9b0;
}

.token.console {
	color: #9cdcfe;
}

.token.parameter {
	color: #9cdcfe;
}

.token.interpolation {
	color: #9cdcfe;
}

.token.punctuation.interpolation-punctuation {
	color: #569cd6;
}

.token.boolean {
	color: #569cd6;
}

.token.property,
.token.variable,
.token.imports .token.maybe-class-name,
.token.exports .token.maybe-class-name {
	color: #9cdcfe;
}

.token.selector {
	color: #d7ba7d;
}

.token.escape {
	color: #d7ba7d;
}

.token.tag {
	color: #569cd6;
}

.token.tag .token.punctuation {
	color: #808080;
}

.token.cdata {
	color: #808080;
}

.token.attr-name {
	color: #9cdcfe;
}

.token.attr-value,
.token.attr-value .token.punctuation {
	color: #ce9178;
}

.token.attr-value .token.punctuation.attr-equals {
	color: #d4d4d4;
}

.token.entity {
	color: #569cd6;
}

.token.namespace {
	color: #4ec9b0;
}
/*********************************************************
* Language Specific
*/

pre[class*='language-javascript'],
code[class*='language-javascript'],
pre[class*='language-jsx'],
code[class*='language-jsx'],
pre[class*='language-typescript'],
code[class*='language-typescript'],
pre[class*='language-tsx'],
code[class*='language-tsx'] {
	color: #9cdcfe;
}

pre[class*='language-css'],
code[class*='language-css'] {
	color: #ce9178;
}

pre[class*='language-html'],
code[class*='language-html'] {
	color: #d4d4d4;
}

.language-regex .token.anchor {
	color: #dcdcaa;
}

.language-html .token.punctuation {
	color: #808080;
}
/*********************************************************
* Line highlighting
*/
pre[class*='language-'] > code[class*='language-'] {
	position: relative;
	z-index: 1;
}

.line-highlight.line-highlight {
	background: #f7ebc6;
	box-shadow: inset 5px 0 0 #f7d87c;
	z-index: 0;
}
src/lib/components/blog/AuthorChip.svelte
<script lang="ts">
  import * as Avatar from "$lib/components/ui/avatar";
  import type { BlogAuthor } from "$lib/content";

  let {
    author,
    size = "sm",
  }: { author: BlogAuthor; size?: "sm" | "default" | "lg" } = $props();

  const initials = $derived(
    author.name
      .split(/\s+/)
      .map((part) => part[0])
      .filter(Boolean)
      .slice(0, 2)
      .join("")
      .toUpperCase(),
  );
</script>

<span class="inline-flex items-center gap-2">
  <Avatar.Root {size}>
    {#if author.avatar}
      <Avatar.Image src={author.avatar} alt={author.name} />
    {/if}
    <Avatar.Fallback class="text-xs">{initials}</Avatar.Fallback>
  </Avatar.Root>
  <span class="text-foreground text-sm font-medium">{author.name}</span>
</span>
src/lib/components/blog/PostCard.svelte
<script lang="ts">
  import * as Card from "$lib/components/ui/card";
  import { Badge } from "$lib/components/ui/badge";
  import AuthorChip from "./AuthorChip.svelte";
  import { formatDate } from "$lib/utils/date";
  import type { BlogPost, BlogPostSummary } from "$lib/content";

  let { post }: { post: BlogPost | BlogPostSummary } = $props();
</script>

<Card.Root class="hover:ring-foreground/20 h-full transition-shadow w-full">
  <Card.Header>
    <Card.Title class="text-lg leading-snug">
      <a href={`/blog/${post.slug}`} class="hover:underline">
        {post.title}
      </a>
    </Card.Title>
    <Card.Description class="line-clamp-2">
      {post.description}
    </Card.Description>
  </Card.Header>
  <Card.Content class="flex flex-1 flex-col justify-end gap-3">
    {#if post.tags?.length}
      <div class="flex flex-wrap gap-1.5">
        {#each post.tags as tag (tag)}
          <Badge href={`/blog/tags/${tag}`} variant="secondary">{tag}</Badge>
        {/each}
      </div>
    {/if}
    <div
      class="text-muted-foreground flex items-center justify-between gap-3 text-xs"
    >
      {#if post.author}
        <AuthorChip author={post.author} />
      {:else}
        <span></span>
      {/if}
      <span class="whitespace-nowrap">
        <time datetime={post.createdDate.toISOString()}
          >{formatDate(post.createdDate)}</time
        >
        <span aria-hidden="true">&bull;</span>
        <span>{post.readingTime} min read</span>
      </span>
    </div>
  </Card.Content>
</Card.Root>
src/lib/content.ts
import type { Component } from "svelte";

export type BlogAuthor = {
  name: string;
  avatar?: string;
};

export type BlogMetadata = {
  title: string;
  description: string;
  createdDate: string;
  updatedDate?: string;
  tags?: string[];
  author?: BlogAuthor;
};

export type BlogModules = Record<
  string,
  { default: Component; metadata: BlogMetadata }
>;

export type BlogRawModules = Record<string, string>;

export type BlogPost = {
  slug: string;
  title: string;
  description: string;
  createdDate: Date;
  updatedDate?: Date;
  tags?: string[];
  author?: BlogAuthor;
  readingTime: number;
  component: Component;
};

export type BlogPostSummary = Omit<BlogPost, "component">;

const WORDS_PER_MINUTE = 220;

function getBlogModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
  }) as BlogModules;
}

function getBlogRawModules() {
  return import.meta.glob("$lib/content/blog/*.svx", {
    eager: true,
    query: "?raw",
    import: "default",
  }) as BlogRawModules;
}

function stripFrontmatter(raw: string) {
  if (!raw.startsWith("---")) return raw;
  const end = raw.indexOf("\n---", 3);
  if (end === -1) return raw;
  return raw.slice(end + 4);
}

function computeReadingTime(raw: string) {
  const body = stripFrontmatter(raw);
  const words = body.split(/\s+/).filter(Boolean).length;
  return Math.max(1, Math.ceil(words / WORDS_PER_MINUTE));
}

export function getBlogPosts(): BlogPost[] {
  const modules = getBlogModules();
  const rawModules = getBlogRawModules();

  const posts = Object.entries(modules).map(([path, module]) => {
    const filename = path.split("/").pop() ?? "";
    const slug = filename.replace(".svx", "");
    const raw = rawModules[path] ?? "";

    return {
      slug,
      title: module.metadata.title,
      description: module.metadata.description,
      createdDate: new Date(module.metadata.createdDate),
      updatedDate: module.metadata.updatedDate
        ? new Date(module.metadata.updatedDate)
        : undefined,
      tags: module.metadata.tags,
      author: module.metadata.author,
      readingTime: computeReadingTime(raw),
      component: module.default,
    };
  });

  posts.sort((a, b) => b.createdDate.getTime() - a.createdDate.getTime());
  return posts;
}

export function getBlogPost(slug: string) {
  const posts = getBlogPosts();
  return posts.find((post) => post.slug === slug) ?? null;
}

export function getBlogPostsByTag(tag: string) {
  return getBlogPosts().filter((post) => post.tags?.includes(tag));
}

export function getAllTags(): { tag: string; count: number }[] {
  const counts = new Map<string, number>();
  for (const post of getBlogPosts()) {
    for (const tag of post.tags ?? []) {
      counts.set(tag, (counts.get(tag) ?? 0) + 1);
    }
  }
  return [...counts.entries()]
    .map(([tag, count]) => ({ tag, count }))
    .sort((a, b) => b.count - a.count || a.tag.localeCompare(b.tag));
}

export function getAdjacentPosts(slug: string): {
  prev: BlogPost | null;
  next: BlogPost | null;
} {
  const posts = getBlogPosts();
  const index = posts.findIndex((post) => post.slug === slug);
  if (index === -1) return { prev: null, next: null };
  return {
    next: index > 0 ? posts[index - 1] : null,
    prev: index < posts.length - 1 ? posts[index + 1] : null,
  };
}

export function getRelatedPosts(slug: string, limit = 3): BlogPost[] {
  const posts = getBlogPosts();
  const current = posts.find((post) => post.slug === slug);
  if (!current?.tags?.length) return [];

  const currentTags = new Set(current.tags);

  return posts
    .filter((post) => post.slug !== slug && post.tags?.length)
    .map((post) => ({
      post,
      overlap: post.tags!.filter((tag) => currentTags.has(tag)).length,
    }))
    .filter(({ overlap }) => overlap > 0)
    .sort(
      (a, b) =>
        b.overlap - a.overlap ||
        b.post.createdDate.getTime() - a.post.createdDate.getTime(),
    )
    .slice(0, limit)
    .map(({ post }) => post);
}
src/lib/content/blog/design-system-notes.svx
---
title: Design System Notes
description: Small choices that make a design system feel coherent — spacing, radius, and the case for fewer variants.
createdDate: 2025-12-15
tags:
  - design
  - shadcn
author:
  name: Ada Chen
---

We've been cleaning up the design system recently. A few notes from the
pass, mostly for ourselves, partially in case they're useful to anyone
else building on top of shadcn.

## The variants we actually use

Every primitive ships with a dozen variants. In practice our product
leans on two or three per component:

- **Buttons**`default`, `ghost`, and `outline`. Destructive shows up
  rarely enough to be worth the extra click.
- **Badges**`default` and `secondary`. Tags and counts, nothing else.
- **Cards** — one shape, with optional footer. Multiple card styles
  became indistinguishable from "padded div," so we stopped.

If a variant hasn't appeared in a design in six months, it gets deleted.
The point isn't minimalism; it's that every extra variant is a decision
a future designer has to relitigate.

## Spacing

Tailwind's default scale is good. We only locked in one rule: never use
odd gaps. `gap-3` and `gap-5` stay; `gap-2.5` goes. It's a small thing
that quietly keeps vertical rhythm predictable across pages.

## Radius

One radius token, applied everywhere. If a surface needs to feel
different, that's a structural decision — a card, a sheet, a popover 
not a one-off `rounded-md`. We ended up at `0.625rem` as our default
and haven't wanted to change it.

## Dark mode

We treat dark mode as a different palette, not a filter. Every token
has an explicit OKLCH value in both themes. When a component looks
off in dark mode, the fix is almost always a token, not a tweak.
src/lib/content/blog/getting-started-with-sveltekit.svx
---
title: Getting Started with SvelteKit
description: The smallest useful SvelteKit project, explained end to end — routing, loading data, and the dev loop.
createdDate: 2026-03-12
tags:
  - sveltekit
  - tutorial
author:
  name: Marco Alvarez
---

SvelteKit is the piece that turns Svelte — a component library — into
something you can ship. Routing, server endpoints, data loading, builds,
and deployment adapters all live in one place.

## Creating a project

The fastest path is the CLI. It scaffolds a minimal project, installs
dependencies, and leaves you with a working dev server.

```bash
npx sv create my-app
cd my-app
npm install
npm run dev
```

## Routes are files

A route is a folder under `src/routes` with a `+page.svelte` inside.
Nested folders produce nested URLs. Dynamic segments use `[brackets]`.

```
src/routes/
  +page.svelte         # /
  about/
    +page.svelte       # /about
  blog/
    [slug]/
      +page.svelte     # /blog/:slug
```

## Loading data

A route's `+page.ts` (or `+page.server.ts` for server-only data) exports
a `load` function. Whatever it returns becomes `data` on the page.

```ts
// src/routes/blog/[slug]/+page.ts
import { error } from '@sveltejs/kit';

export const load = async ({ params }) => {
  const post = await fetchPost(params.slug);
  if (!post) throw error(404, 'Not found');
  return { post };
};
```

That's most of SvelteKit. The rest — form actions, hooks, adapters — are
opt-in once you need them.
src/lib/content/blog/performance-checklist.svx
---
title: A Pragmatic Performance Checklist
description: The small set of things that have given us the biggest performance wins — in rough order of impact.
createdDate: 2025-11-03
tags:
  - performance
  - tutorial
author:
  name: Marco Alvarez
---

We don't have a performance budget so much as a performance allergy.
When something feels slow, we profile, fix the obvious thing, and move
on. The list below is what "the obvious thing" usually is.

> Premature optimization is the root of all evil, but ignoring a 3s
> cold-start isn't optimization — it's negligence.

## The checklist

1. **Measure with real numbers.** Lighthouse and the Network tab answer
   most questions. If you're not measuring, you're guessing.
2. **Serve fewer bytes.** Compress images (AVIF or WebP), remove unused
   fonts, and audit any dependency over 50KB gzipped.
3. **Defer the non-critical.** Scripts that aren't needed above the
   fold can wait. `loading="lazy"` on images, `defer` on scripts, and
   dynamic `import()` for interactive widgets.
4. **Cache at the edge.** If the same HTML could serve a thousand users,
   it should. Use your adapter's cache headers; don't hand-roll it.
5. **Avoid waterfalls.** If page load depends on three sequential fetches,
   collapse them into one endpoint or preload the critical ones.
6. **Trust the framework.** SvelteKit's `load` functions run in parallel
   by default and the compiler already strips unused code. Most hand-rolled
   cleverness just gets in the way.

## What we stopped worrying about

- Micro-optimizations inside component code — the bundle is small, the
  diff is fast. The savings are real but rarely matter.
- Third-party tooling that promises "speed." If it adds a build step,
  the benefit needs to be measurable, not theoretical.

## When to go deeper

If the checklist doesn't move the needle, the next step is almost
always a deeper look at the data layer — query plans, N+1 fetches, cold
cache behavior. But you should have exhausted the list above before
getting there. Most slowness is boring, and most fixes are too.
src/lib/content/blog/welcome-to-the-blog.svx
---
title: Welcome to the Blog
description: A quick note on what this space is for and what to expect from us.
createdDate: 2026-04-20
tags:
  - announcements
author:
  name: Ada Chen
---

Welcome in. This blog is where we write about the things we build, the
decisions behind them, and what we learn when they go sideways.

You can expect a mix of short notes and longer walkthroughs. No schedule,
no filler. If a post exists it's because something was worth writing down.

A few threads you'll see pop up often:

- Product notes on what we're shipping and why
- Practical tutorials for the tools we use every day
- Design and engineering decisions that took more than five minutes

If you'd rather not check back manually, the [RSS feed](/blog/rss.xml)
has every post. Thanks for reading.
src/lib/content/blog/why-we-chose-tailwind-v4.svx
---
title: Why We Chose Tailwind v4
description: Notes on moving from a traditional CSS pipeline to Tailwind v4's zero-config, CSS-first setup.
createdDate: 2026-02-18
tags:
  - tailwind
  - design
author:
  name: Ada Chen
---

Tailwind v4 is a meaningful rewrite. The surface API still looks the same
— utility classes on elements — but the compiler, configuration model,
and theming story all changed in ways that made us want to adopt it.

> The point of a design system isn't to ship fewer classes. It's to make
> the next decision easier than the last.

## What changed for us

Three things mattered:

1. **CSS-first config.** `tailwind.config.js` is gone. Tokens live in
   `app.css` as real CSS custom properties, which means they also work
   outside of Tailwind — SVG fills, inline styles, wherever.
2. **OKLCH colors.** Designers can reason about lightness and chroma
   independently. Contrast checks become much easier to eyeball.
3. **Speed.** The new engine compiles fast enough that we stopped
   thinking about purge lists. Dev feedback is effectively instant.

## The config we kept

We pinned two things in `app.css`:

```css
@import 'tailwindcss';
@plugin '@tailwindcss/typography';

:root {
  --radius: 0.625rem;
  --background: oklch(1 0 0);
  --foreground: oklch(0.129 0.042 264.695);
}
```

Everything else — spacing, typography, breakpoints — falls back to
defaults until we have a reason to override it.

## What we'd tell a team evaluating it

If you're already using Tailwind v3, the migration is mostly a config
move. If you're starting from scratch, v4 removes a pile of decisions
you'd otherwise be making on day one. That alone made it worth it.
src/lib/content/blog/writing-posts-with-mdsvex.svx
---
title: Writing Posts with MDsveX
description: How this blog turns markdown files into real Svelte components — frontmatter, code, and embedded UI.
createdDate: 2026-01-28
updatedDate: 2026-02-02
tags:
  - mdsvex
  - tutorial
author:
  name: Priya Shah
---

Posts on this blog are `.svx` files. Each one is a real Svelte component
at build time, so Markdown and Svelte sit side by side with no glue
code.

## Frontmatter

YAML at the top of a post becomes the `metadata` export. The loader
reads it to build the list view, tag pages, and RSS feed.

```yaml
---
title: Writing Posts with MDsveX
description: A short description shown in previews.
createdDate: 2026-01-28
tags:
  - mdsvex
  - tutorial
author:
  name: Priya Shah
---
```

## Markdown works the way you'd expect

Headings, lists, links, blockquotes, and inline `code` all render.
Fenced code blocks pick up language hints for syntax highlighting when a
plugin is enabled.

```ts
function greet(name: string) {
  return `hello, ${name}`;
}
```

## Images

Put images in `static/` and reference them with a root-relative path:

```md
![A screenshot](/screenshots/home.png)
```

## Svelte in your posts

The superpower is that you can drop components straight into a post —
a chart, an interactive demo, a call-to-action card. The rules are the
same as any other Svelte file:

```svelte
<script>
  import Callout from '$lib/components/Callout.svelte';
</script>

<Callout>This is a real component inside a markdown post.</Callout>
```

That's the whole pipeline. Write markdown, sprinkle in components when
plain text stops being enough, and the rest is just Svelte.
src/lib/utils/date.ts
const formatter = new Intl.DateTimeFormat("en-US", {
  year: "numeric",
  month: "long",
  day: "numeric",
});

export function formatDate(date: Date) {
  return formatter.format(date);
}
src/routes/(public)/blog/[slug]/+page.svelte
<script lang="ts">
  import { Badge } from "$lib/components/ui/badge";
  import { Separator } from "$lib/components/ui/separator";
  import AuthorChip from "$lib/components/blog/AuthorChip.svelte";
  import PostCard from "$lib/components/blog/PostCard.svelte";
  import { formatDate } from "$lib/utils/date";
  import * as Breadcrumb from "$lib/components/ui/breadcrumb";
  import ArrowLeft from "@lucide/svelte/icons/arrow-left";
  import ArrowRight from "@lucide/svelte/icons/arrow-right";
  import { page } from "$app/state";

  let { data } = $props();
  let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
    title: string;
    url: string;
  }[];
</script>

<div class="flex flex-col gap-6">
  <header class="flex flex-col gap-5 border-b border-border pb-8">
    <div class="flex flex-col gap-3">
      <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">
        {data.blogPost.title}
      </h1>
      <p class="text-muted-foreground text-lg">
        {data.blogPost.description}
      </p>
    </div>

    <div
      class="text-muted-foreground flex flex-wrap items-center gap-x-1 gap-y-2 text-sm"
    >
      {#if data.blogPost.author}
        <AuthorChip author={data.blogPost.author} />
        <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
      {/if}
      <time datetime={data.blogPost.createdDate.toISOString()}>
        {formatDate(data.blogPost.createdDate)}
      </time>
      {#if data.blogPost.updatedDate}
        <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
        <span>Updated {formatDate(data.blogPost.updatedDate)}</span>
      {/if}
      <span aria-hidden="true" class="text-muted-foreground/50">&bull;</span>
      <span>{data.blogPost.readingTime} min read</span>
    </div>

    {#if data.blogPost.tags?.length}
      <div class="flex flex-wrap gap-1.5">
        {#each data.blogPost.tags as tag (tag)}
          <Badge href={`/blog/tags/${tag}`} variant="secondary">{tag}</Badge>
        {/each}
      </div>
    {/if}
  </header>

  <article
    class="prose dark:prose-invert max-w-prose prose-headings:mb-0 prose-headings:mt-4"
  >
    <data.blogPost.component />
  </article>

  <Separator />

  <nav class="grid gap-3 sm:grid-cols-2" aria-label="Post navigation">
    {#if data.adjacent.prev}
      <a
        href={`/blog/${data.adjacent.prev.slug}`}
        class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 ring-1 transition-shadow"
      >
        <span
          class="text-muted-foreground inline-flex items-center gap-1 text-xs font-medium uppercase tracking-wide"
        >
          <ArrowLeft class="size-3.5" />
          Previous
        </span>
        <span class="font-medium group-hover:underline"
          >{data.adjacent.prev.title}</span
        >
      </a>
    {:else}
      <div></div>
    {/if}

    {#if data.adjacent.next}
      <a
        href={`/blog/${data.adjacent.next.slug}`}
        class="ring-foreground/10 hover:ring-foreground/20 bg-card text-card-foreground group flex flex-col gap-1 rounded-xl p-4 text-right ring-1 transition-shadow sm:col-start-2"
      >
        <span
          class="text-muted-foreground inline-flex items-center justify-end gap-1 text-xs font-medium uppercase tracking-wide"
        >
          Next
          <ArrowRight class="size-3.5" />
        </span>
        <span class="font-medium group-hover:underline"
          >{data.adjacent.next.title}</span
        >
      </a>
    {/if}
  </nav>

  {#if data.related.length}
    <section class="flex flex-col gap-4">
      <Separator />
      <h2 class="text-xl font-semibold tracking-tight">Related posts</h2>
      <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
        {#each data.related as post (post.slug)}
          <li class="flex">
            <PostCard {post} />
          </li>
        {/each}
      </ul>
    </section>
  {/if}
</div>
src/routes/(public)/blog/[slug]/+page.ts
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import {
  getAdjacentPosts,
  getBlogPost,
  getBlogPosts,
  getRelatedPosts,
} from "$lib/content";

export const load = async ({ params, parent }) => {
  await parent();

  const blogPost = getBlogPost(params.slug);

  if (blogPost === null) {
    throw error(404, "Page not found");
  }

  const adjacent = getAdjacentPosts(params.slug);
  const related = getRelatedPosts(params.slug);

  const pageMetaTags = definePageMetaTags({
    title: blogPost.title,
    description: blogPost.description,
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
    { title: blogPost.title, url: `/blog/${blogPost.slug}` },
  ];

  return { blogPost, adjacent, related, breadcrumbs, ...pageMetaTags };
};

export const entries = async () => {
  return getBlogPosts().map((post) => ({ slug: post.slug }));
};
src/routes/(public)/blog/+layout.svelte
<script lang="ts">
  import * as Breadcrumb from "$lib/components/ui/breadcrumb";
  import { page } from "$app/state";

  let { children } = $props();

  let breadcrumbs = $derived(page.data.breadcrumbs || []) as {
    title: string;
    url: string;
  }[];
</script>

<section data-role="content">
  <Breadcrumb.Root class="mb-6">
    <Breadcrumb.List>
      {#if breadcrumbs.length > 1}
        {#each breadcrumbs.slice(0, -1) as breadcrumb}
          <Breadcrumb.Item>
            <Breadcrumb.Link href={breadcrumb.url}
              >{breadcrumb.title}</Breadcrumb.Link
            >
          </Breadcrumb.Item>
          <Breadcrumb.Separator />
        {/each}
      {/if}
      <Breadcrumb.Page>
        {breadcrumbs[breadcrumbs.length - 1].title}
      </Breadcrumb.Page>
    </Breadcrumb.List>
  </Breadcrumb.Root>

  {@render children?.()}
</section>
src/routes/(public)/blog/+page.svelte
<script lang="ts">
  import { Badge } from "$lib/components/ui/badge";
  import { Button } from "$lib/components/ui/button";
  import PostCard from "$lib/components/blog/PostCard.svelte";
  import Rss from "@lucide/svelte/icons/rss";

  let { data } = $props();
</script>

<div class="flex flex-col gap-10">
  <header
    class="flex flex-col gap-4 md:flex-row md:items-end md:justify-between"
  >
    <div class="max-w-2xl">
      <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">Blog</h1>
      <p class="text-muted-foreground mt-2 text-base">
        Product notes, tutorials, and engineering posts from the team.
      </p>
    </div>
    <Button href="/blog/rss.xml" variant="outline" size="sm" class="w-fit">
      <Rss class="size-4" />
      RSS
    </Button>
  </header>

  <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
    {#each data.blogPosts as post (post.slug)}
      <li class="flex w-full">
        <PostCard {post} />
      </li>
    {/each}
  </ul>
</div>
src/routes/(public)/blog/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPosts } from "$lib/content";

export const load = async ({ parent }) => {
  await parent();

  const blogPosts = getBlogPosts();
  const allTags = getAllTags();

  const pageMetaTags = definePageMetaTags({
    title: "Blog",
    description: "Product notes, tutorials, and engineering posts.",
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
  ];

  return { blogPosts, allTags, breadcrumbs, ...pageMetaTags };
};
src/routes/(public)/blog/rss.xml/+server.ts
import { getBlogPosts } from "$lib/content";

export const prerender = true;

function escapeXml(value: string) {
  return value
    .replace(/&/g, "&amp;")
    .replace(/</g, "&lt;")
    .replace(/>/g, "&gt;")
    .replace(/"/g, "&quot;")
    .replace(/'/g, "&apos;");
}

export const GET = async ({ locals }) => {
  const { appName, appURL } = locals.meta;
  const posts = getBlogPosts();
  const siteURL = appURL.replace(/\/$/, "");
  const feedURL = `${siteURL}/blog/rss.xml`;
  const lastBuild = (
    posts[0]?.updatedDate ??
    posts[0]?.createdDate ??
    new Date()
  ).toUTCString();

  const items = posts
    .map((post) => {
      const link = `${siteURL}/blog/${post.slug}`;
      const pubDate = post.createdDate.toUTCString();
      const categories = (post.tags ?? [])
        .map((tag) => `    <category>${escapeXml(tag)}</category>`)
        .join("\n");
      const creator = post.author
        ? `    <dc:creator>${escapeXml(post.author.name)}</dc:creator>`
        : "";

      return [
        "  <item>",
        `    <title>${escapeXml(post.title)}</title>`,
        `    <link>${escapeXml(link)}</link>`,
        `    <guid isPermaLink="true">${escapeXml(link)}</guid>`,
        `    <description>${escapeXml(post.description)}</description>`,
        `    <pubDate>${pubDate}</pubDate>`,
        creator,
        categories,
        "  </item>",
      ]
        .filter(Boolean)
        .join("\n");
    })
    .join("\n");

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
<channel>
  <title>${escapeXml(appName)}</title>
  <link>${escapeXml(`${siteURL}/blog`)}</link>
  <atom:link href="${escapeXml(feedURL)}" rel="self" type="application/rss+xml" />
  <description>${escapeXml(`Latest posts from ${appName}.`)}</description>
  <language>en</language>
  <lastBuildDate>${lastBuild}</lastBuildDate>
${items}
</channel>
</rss>
`;

  return new Response(xml, {
    headers: {
      "Content-Type": "application/xml; charset=utf-8",
      "Cache-Control": "max-age=0, s-maxage=3600",
    },
  });
};
src/routes/(public)/blog/tags/[tag]/+page.svelte
<script lang="ts">
  import PostCard from "$lib/components/blog/PostCard.svelte";

  let { data } = $props();
</script>

<div class="flex flex-col gap-8">
  <header class="flex flex-col gap-2">
    <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">
      {data.tag}
    </h1>
    <p class="text-muted-foreground text-base">
      {data.blogPosts.length}
      {data.blogPosts.length === 1 ? "post" : "posts"} tagged "{data.tag}".
    </p>
  </header>

  <ul class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
    {#each data.blogPosts as post (post.slug)}
      <li class="flex">
        <PostCard {post} />
      </li>
    {/each}
  </ul>
</div>
src/routes/(public)/blog/tags/[tag]/+page.ts
import { error } from "@sveltejs/kit";
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags, getBlogPostsByTag } from "$lib/content";

export const load = async ({ params, parent }) => {
  await parent();

  const blogPosts = getBlogPostsByTag(params.tag);

  if (blogPosts.length === 0) {
    throw error(404, "Tag not found");
  }

  const pageMetaTags = definePageMetaTags({
    title: `Posts tagged "${params.tag}"`,
    description: `Blog posts tagged "${params.tag}".`,
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
    { title: "Tags", url: "/blog/tags" },
    { title: params.tag, url: `/blog/tags/${params.tag}` },
  ];

  return { blogPosts, tag: params.tag, breadcrumbs, ...pageMetaTags };
};

export const entries = async () => {
  return getAllTags().map(({ tag }) => ({ tag }));
};
src/routes/(public)/blog/tags/+page.svelte
<script lang="ts">
  import { Badge } from "$lib/components/ui/badge";

  let { data } = $props();
</script>

<div class="flex flex-col gap-8">
  <header class="flex flex-col gap-2">
    <h1 class="text-3xl font-semibold tracking-tight sm:text-4xl">Tags</h1>
    <p class="text-muted-foreground text-base">
      {data.allTags.length}
      {data.allTags.length === 1 ? "tag" : "tags"} used across the blog.
    </p>
  </header>

  {#if data.allTags.length}
    <ul class="flex flex-wrap gap-2">
      {#each data.allTags as { tag, count } (tag)}
        <li>
          <Badge href={`/blog/tags/${tag}`} variant="secondary">
            {tag}
            <span class="text-muted-foreground ml-0.5">{count}</span>
          </Badge>
        </li>
      {/each}
    </ul>
  {:else}
    <p class="text-muted-foreground">No tags yet.</p>
  {/if}
</div>
src/routes/(public)/blog/tags/+page.ts
import { definePageMetaTags } from "svelte-meta-tags";
import { getAllTags } from "$lib/content";

export const load = async ({ parent }) => {
  await parent();

  const allTags = getAllTags();

  const pageMetaTags = definePageMetaTags({
    title: "Tags",
    description: "All tags used on the blog.",
  });

  const breadcrumbs = [
    { title: "Home", url: "/" },
    { title: "Blog", url: "/blog" },
    { title: "Tags", url: "/blog/tags" },
  ];

  return { allTags, breadcrumbs, ...pageMetaTags };
};