Build a Blog with Svelte
In How I Built This Blog, I gave a high-level overview of how I built this site with SvelteKit. This tutorial takes a closer look at the implementation.
Most tutorials on building a Svelte blog recommend some variation of the same stack:
-
Use MDsveX to preprocess Markdown
-
Extend its processing pipeline with remark and rehype
This stack is probably the easiest way to get started. But if you want to map Markdown tokens directly to your own Svelte components, you may find it less flexible and awkward to extend cleanly. 
In this tutorial, we'll build a custom Markdown preprocessor and use it to render Svelte components and process assets at build time. Then we'll add post loading, metadata handling, discovery files, and static deployment.
This tutorial assumes that you know the basics of Svelte and SvelteKit and can write Markdown.
If either is new to you, start with:
You will also need:
-
A JavaScript runtime: Node.js or Bun
-
A package manager: npm, pnpm, or Bun
Create and Set Up the Project
First, let's create a SvelteKit project and configure the tools we'll use throughout the tutorial.
bunx sv create my-blogI recommend the following choices:
-
Which template would you like?
SvelteKit minimal -
Add type checking with TypeScript?
Yes, using TypeScript syntax -
What would you like to add to your project?
Press ENTER without selecting anything -
Which package manager do you want to install dependencies with?
bun (or the one you use currently)
Once the installation finishes, you'll see output like this (this example uses Bun). If it stalls, check your network connection.
◇ What's next? ───────────────────────────────╮
│ │
│ 📁 Project steps │
│ │
│ 1: cd my-blog │
│ 2: bun run dev --open │
│ │
│ To close the dev server, hit Ctrl-C │
│ │
│ Stuck? Visit us at https://svelte.dev/chat │
│ │
├──────────────────────────────────────────────╯
│
└ You're all set!If you have used SvelteKit before, you may notice that current SvelteKit v2 templates no longer include a svelte.config.js file. Instead, they pass the SvelteKit and Svelte configuration directly to the Vite plugin in vite.config.ts.
SvelteKit v2.62 introduced this configuration style ahead of v3, which no longer supports svelte.config.js. Adopting it in v2 makes the eventual migration easier.
This tutorial follows the new layout and uses vite.config.ts throughout.
Configure Static Output
Next, install @sveltejs/adapter-static.
bun add -D @sveltejs/adapter-staticOpen vite.config.ts and change the adapter import.
From:
import adapter from "@sveltejs/adapter-auto";To:
import adapter from "@sveltejs/adapter-static";Next, configure the adapter. Setting fallback to 404.html tells SvelteKit to generate a fallback document for your static host.
import adapter from "@sveltejs/adapter-static";
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter({
fallback: "404.html",
}),
}),
],
});After saving vite.config.ts, remove the old adapter:
bun remove @sveltejs/adapter-autofallbackAs the SvelteKit documentation explains, a fallback page is the entry point for URLs that cannot be prerendered. It doesn't have to be named 404.html. That said, one of its most common uses is handling missing pages on static hosts.
Typical use cases include:
-
Rendering an error page on the client
-
Handling dynamic routes such as
[...slug], where the slug cannot be determined at build time
A fallback is a client-rendered catch-all, not a substitute for prerendering. Use it for cases such as 404 handling, and continue to prerender content that is known at build time, including your blog posts.
Run Project Tools with Bun
If you chose Bun and want locally installed CLIs to run under Bun rather than Node.js, update package.json. Bun normally respects a CLI's Node.js shebang; the --bun flag overrides it.
Prefix each command with bun --bun:
{
"scripts": {
"dev": "bun --bun vite dev",
"build": "bun --bun vite build",
"preview": "bun --bun vite preview",
"prepare": "bun --bun svelte-kit sync || echo ''",
"check": "bun --bun svelte-kit sync && bun --bun svelte-check --tsconfig ./tsconfig.json",
"check:watch": "bun --bun svelte-kit sync && bun --bun svelte-check --tsconfig ./tsconfig.json --watch"
}
}It's a good idea to configure a formatter for your codebase.
You could use Oxfmt, for example. Install oxfmt as a development dependency, run oxfmt --init, enable the svelte option in .oxfmtrc.json, and add an oxfmt script to package.json. If you're forcing Bun as the runtime, use bun --bun oxfmt in that script.
At this point, your project should look something like this:
We'll leave visual styling aside and focus on the content pipeline.
Build a Markdown Preprocessor
Svelte preprocessors turn source code the compiler can't understand directly into code it can. For example, a preprocessor can convert Sass into plain CSS before Svelte compiles a component.
A Markdown preprocessor does the same thing for Markdown: it converts .md files into valid Svelte code.
Off-the-shelf Markdown preprocessors such as MDsveX are powerful. For this project, though, we want direct control over how each Markdown token maps to a Svelte component, so we'll write our own.
We'll use Markdown Exit to parse and render Markdown, and Shiki to highlight fenced code blocks.
bun add -D markdown-exit shikiI chose it primarily because it follows the architecture of markdown-it:
-
Its token- and rule-based design makes the output highly configurable.
For example, you can renderparagraph_openas<Paragraph>andparagraph_closeas</Paragraph>to use your own paragraph component. -
It is extensible through plugins.
It also provides modern TypeScript support and asynchronous rendering, which fits the asynchronous Shiki API used later in this tutorial.
Create the Preprocessor Entry Point
We need an entry point for the preprocessor, along with some configuration that tells Svelte to accept .md files and pass them through it.
Create a src/preprocess directory, then add main.ts and markdown.ts:
Edit main.ts:
import type { PreprocessorGroup } from "svelte/compiler";
import { processMD } from "./markdown.ts";
export function preprocess(): PreprocessorGroup {
return {
name: "preprocess",
markup: async ({ content, filename }) => {
if (filename?.endsWith(".md")) {
return {
code: await processMD(content, filename),
};
}
},
};
}Edit markdown.ts:
export function processMD(_content: string, _filename: string): string {
// Accept Markdown content and return Svelte code.
// We don't have any processing logic yet, so return a placeholder for now.
return "<h1>hello from markdown</h1>";
}In main.ts, we define a PreprocessorGroup containing a markup preprocessor. A markup preprocessor receives the entire contents of a component and returns the transformed source.
When the filename ends in .md, the preprocessor passes its contents to processMD in markdown.ts. That function must return valid Svelte source code.
Next, update vite.config.ts so that Svelte accepts .md files and passes them through our preprocessor:
import { preprocess } from "./src/preprocess/main.ts";
export default defineConfig({
plugins: [
sveltekit({
adapter: adapter({
fallback: "404.html",
}),
preprocess: [preprocess()],
extensions: [".svelte", ".md"],
}),
],
});To test the setup, replace +page.svelte with +page.md:
Visit /. You should see hello from markdown no matter what +page.md contains, because processMD currently returns that hardcoded output.
Parse and Render Markdown
Now let's implement processMD. The simplest version lets Markdown Exit convert the source to HTML with its default settings:
import { createMarkdownExit } from "markdown-exit";
// Create a Markdown Exit instance.
const md = createMarkdownExit();
export async function processMD(content: string, _filename: string): Promise<string> {
// Render a Markdown string as HTML.
return md.renderAsync(content);
}Add some Markdown to +page.md. It will be unstyled, but it should render correctly.
We're using renderAsync rather than render. This example doesn't need any asynchronous work yet, but Shiki syntax highlighting (which we'll add later in the tutorial) will.
For our purposes, though, the renderAsync convenience method hides too much of the pipeline, making it hard to step in between parsing and rendering. Instead, split the work into two phases: call md.parse to produce tokens, then pass those tokens to md.renderer.renderAsync after making any changes:
export async function processMD(content: string, _filename: string): Promise<string> {
const tokens = md.parse(content);
return md.renderer.renderAsync(tokens, md.options);
}Understand Tokens and Renderer Rules
Before we build our own rules and components, let's take a quick look at how Markdown Exit works.
First, md.parse converts the Markdown into a token stream:
const tokens = md.parse(content);Each Token represents part of the parsed Markdown structure. Here is a highly simplified version of the class:
class Token {
/**
* The token type, such as "paragraph_open".
*/
type: string;
/**
* A nested token stream, used by inline tokens.
*/
children: Token[] | null;
/**
* The content of a self-contained token, such as code, HTML, or a fence.
*/
content: string;
/**
* - Info string for "fence" tokens
* - The value "auto" for autolink "link_open" and "link_close" tokens
* - The string value of the item marker for ordered-list "list_item_open" tokens
* - Label string of "reference" tokens
*/
info: string;
}For example, this fenced code block:
```svelte 1
<p>para</p>
```produces a token like this:
Token {
type: "fence",
children: null,
content: "<p>para</p>\n",
info: "svelte 1",
}Opening and closing tokens remain separate entries in the stream. The renderer processes them in order, so the emitted markup preserves their nesting. This means an individual renderer rule doesn't need to render the nested structure recursively.
We'll customize Markdown Exit with render rules. A render rule turns a token into an output string—usually HTML or Svelte markup—and is registered under the token's type. For example, the paragraph_open rule handles paragraph_open tokens and outputs <p> by default.
Conceptually, you can define and register a render rule like this:
// Definition
function token_type(
tokens: Token[],
idx: number,
options: RenderOptions,
env: any,
self: Renderer,
): string | Promise<string> {
return "...";
}
// Replace the rule in Markdown Exit's renderer.
md.renderer.rules.token_type = token_type;Markdown Exit passes the full token array as tokens, while idx identifies the current token. options contains the rendering options, env carries shared context, and self is the renderer instance. The rule returns the rendered string for that token, either directly or as a promise.
Render Markdown with Svelte Components
Paragraphs
Let's start with Paragraph, one of the simplest components in our pipeline.
Add paragraph.ts for the renderer rules and Paragraph.svelte for the component, giving the project this structure:
Paragraph.svelte wraps its children in a <p> element:
<script lang="ts">
import type { Snippet } from "svelte";
let { children }: { children: Snippet } = $props();
</script>
<p>
{@render children()}
</p>In paragraph.ts, map the paragraph_open token to <Paragraph> and paragraph_close to </Paragraph>:
import type { Renderer, RenderOptions, Token } from "markdown-exit";
export async function paragraph_open(
_tokens: Token[],
_idx: number,
_options: RenderOptions,
_env: any,
_self: Renderer,
): Promise<string> {
return `<Paragraph>`;
}
export async function paragraph_close(
_tokens: Token[],
_idx: number,
_options: RenderOptions,
_env: any,
_self: Renderer,
): Promise<string> {
return `</Paragraph>`;
}Edit markdown.ts to replace the default paragraph rules and import the Paragraph component into the generated Svelte module:
import { createMarkdownExit } from "markdown-exit";
import { paragraph_open, paragraph_close } from "./markdown/paragraph.ts";
const md = createMarkdownExit();
md.renderer.rules.paragraph_open = paragraph_open;
md.renderer.rules.paragraph_close = paragraph_close;
export async function processMD(content: string, _filename: string): Promise<string> {
const tokens = md.parse(content);
const script = `
<script lang="ts" module>
import Paragraph from "$lib/components/Paragraph.svelte";
</script>
`;
return script + (await md.renderer.renderAsync(tokens, md.options));
}Markdown paragraphs now render through our Paragraph component, giving us one place to customize their markup, behavior, and styling.
Fenced Code Blocks
Next, let's build a Fence component for fenced code blocks. Unlike the paragraph rules, the fence rule reads data from the current token and passes it to a Svelte component as props.
Create Fence.svelte for the component and fence.ts for its render rule:
The flow in fence.ts has three steps. First, read content and info from the current token. content holds the source code, while info holds the text after the opening fence—usually a language name followed by optional metadata. Then highlight the code and pass both versions to the component: the raw text for copying and the highlighted markup for display.
Because the rule generates Svelte source, both values must be embedded safely. JSON.stringify serializes the raw code as a JavaScript string literal for the code prop. escapeBraces replaces braces in the highlighted markup with character references so that Svelte does not interpret code such as { value } as an expression.
import type { Renderer, RenderOptions, Token } from "markdown-exit";
import { codeToHtml } from "shiki";
export async function fence(
tokens: Token[],
idx: number,
_options: RenderOptions,
_env: unknown,
_self: Renderer,
): Promise<string> {
const { content, info } = tokens[idx];
const lang = info.trim().split(/\s+/, 1)[0] || "text";
const html = await codeToHtml(content.trimEnd(), {
lang,
theme: "catppuccin-mocha",
});
return `<Fence code={${JSON.stringify(content)}}>${escapeBraces(html)}</Fence>`;
}
function escapeBraces(str: string): string {
return str.replace(/[{}]/g, (char) => `&#${char.charCodeAt(0)};`);
}Fence.svelte can stay small. This version renders the highlighted markup and adds a simple copy button; feel free to style it however you like.
<script lang="ts">
import type { Snippet } from "svelte";
let { code, children }: { code: string; children: Snippet } = $props();
async function copyToClipboard(): Promise<void> {
await navigator.clipboard.writeText(code);
}
</script>
<button type="button" onclick={copyToClipboard}>Copy</button>
{@render children()}navigator.clipboard.writeText requires a secure context. It may therefore be unavailable when you open a development server from another device over plain HTTP.
It should work once your production site is served over HTTPS.
Finally, register the fence rule in markdown.ts:
import { fence } from "./markdown/fence.ts";
md.renderer.rules.fence = fence;Also import Fence.svelte in the module script generated by processMD, alongside Paragraph.svelte:
const script = `
<script lang="ts" module>
import Paragraph from "$lib/components/Paragraph.svelte";
import Fence from "$lib/components/Fence.svelte";
</script>
`;Process Images at Build Time
Preprocessing is especially useful for a static blog because moving work from the browser to the build step reduces the amount of work needed at runtime. We've already done this by highlighting fenced code blocks during preprocessing.
Next, we'll build an image component and move image processing into the same stage. Start by installing @sveltejs/enhanced-img, which generates multiple formats and sizes at build time so the browser can choose the most appropriate source.
bun add -D @sveltejs/enhanced-imgRegister it as a Vite plugin in vite.config.ts. enhancedImages() must appear before sveltekit(). The core configuration should now look like this; keep any additional compilerOptions generated by the CLI:
import adapter from "@sveltejs/adapter-static";
import { enhancedImages } from "@sveltejs/enhanced-img";
import { sveltekit } from "@sveltejs/kit/vite";
import { defineConfig } from "vite";
import { preprocess } from "./src/preprocess/main.ts";
export default defineConfig({
plugins: [
enhancedImages(),
sveltekit({
adapter: adapter({
fallback: "404.html",
}),
preprocess: [preprocess()],
extensions: [".svelte", ".md"],
}),
],
});In a Svelte file, adding the enhanced query to a local raster-image import produces a processed Picture object. The Picture type is exported by @sveltejs/enhanced-img; SVG imports resolve to URL strings instead.
<script lang="ts">
import image from "/path/to/an/image.png?enhanced";
</script>The generated import needs a Vite-compatible path, but we should still be able to reference an image relative to the post that uses it.
We can resolve that relative path against the location of the Markdown file during preprocessing.
Render rules do not receive the source filename directly, so the preprocessor must pass it through the shared env object.
In markdown.ts, convert filename to a root-relative Vite path, store it as env.sourcePath, and pass the same environment object to both the parser and renderer:
import path from "node:path";
export interface MarkdownEnv extends Record<string, unknown> {
sourcePath: string;
}
export async function processMD(content: string, filename: string): Promise<string> {
const sourcePath = toVitePath(filename);
const env: MarkdownEnv = {
sourcePath,
};
const tokens = md.parse(content, env);
return md.renderer.renderAsync(tokens, md.options, env);
}
export function toVitePath(filename: string): string {
const relativePath = path.relative(process.cwd(), filename);
const posixPath = relativePath.split(path.sep).join("/");
return posixPath.startsWith("/") ? posixPath : `/${posixPath}`;
}In image.ts, read src and alt from the image token, then resolve src relative to the directory containing the Markdown file:
import type { Renderer, RenderOptions, Token } from "markdown-exit";
import path from "node:path";
export async function image(
tokens: Token[],
idx: number,
_options: RenderOptions,
env: { sourcePath: string },
_self: Renderer,
): Promise<string> {
const token = tokens[idx];
const rawSrc = token.attrGet("src") ?? "";
const alt = token.content;
const src = path.posix.join(path.posix.dirname(env.sourcePath), rawSrc);
// We now have a Vite-compatible `src` and plain-text `alt`.
return `...`;
}Later, when we cover content management, we'll use the following layout for each post. With this structure, Markdown can reference the image as assets/image.png:
How should we pass src and alt to the component? We cannot pass a variable directly to import.meta.glob, because Vite requires all glob arguments to be literals.
One possible workaround is to pass the resolved path directly to the component:
return `<Image src={${JSON.stringify(src)}} alt={${JSON.stringify(alt)}} />`;Image.svelte could then eagerly import every image under the posts directory and select the requested one by its path:
<script lang="ts">
import type { Picture } from "@sveltejs/enhanced-img";
let { src, alt }: { src: string; alt: string } = $props();
const images = import.meta.glob<Picture>(["/path/to/posts/**/*.{jpg,jpeg,png,webp,avif}"], {
eager: true,
query: {
enhanced: true,
},
import: "default",
});
const image = $derived(images[src]);
</script>This works, but because the glob is eager, Vite processes every matching image and bundles all of them—even if they aren't referenced anywhere else in your code. That prevents us from limiting each generated post to the assets it actually uses.
Instead, the preprocessor can generate one static import for each image referenced by a post and collect those imports in the generated module-level <script> block.
Extend env with a moduleStatements array and an assetId counter. The image rule uses the counter to assign each asset a unique identifier and appends its import to the array. After rendering finishes, join those statements into the generated script:
export interface MarkdownEnv extends Record<string, unknown> {
sourcePath: string;
moduleStatements: string[];
assetId: number;
}
export async function processMD(content: string, filename: string): Promise<string> {
const env: MarkdownEnv = {
sourcePath: toVitePath(filename),
moduleStatements: [],
assetId: 0,
};
const tokens = md.parse(content, env);
const markup = await md.renderer.renderAsync(tokens, md.options, env);
const script = `
<script lang="ts" module>
import Paragraph from "$lib/components/Paragraph.svelte";
import Fence from "$lib/components/Fence.svelte";
import Image from "$lib/components/Image.svelte";
${env.moduleStatements.join("\n")}
</script>
`;
return script + markup;
}The rule can now generate a literal ?enhanced import for each referenced image. Because the path is serialized into the generated source, Vite sees a normal static import. Create image.ts with the complete rule:
import type { Renderer, RenderOptions, Token } from "markdown-exit";
import type { MarkdownEnv } from "../markdown.ts";
import path from "node:path";
export async function image(
tokens: Token[],
idx: number,
_options: RenderOptions,
env: MarkdownEnv,
_self: Renderer,
): Promise<string> {
const token = tokens[idx];
const rawSrc = token.attrGet("src") ?? "";
const alt = token.content;
const src = path.posix.join(path.posix.dirname(env.sourcePath), rawSrc);
const assetId = ++env.assetId;
const importPath = `${src}?enhanced`;
env.moduleStatements.push(`import asset_${assetId} from ${JSON.stringify(importPath)};`);
return `<Image src={asset_${assetId}} alt={${JSON.stringify(alt)}} />`;
}Import and register the image rule in markdown.ts, just as we did for paragraphs and fences:
import { image } from "./markdown/image.ts";
md.renderer.rules.image = image;Each generated asset_${assetId} is an enhanced Picture object containing sources in multiple image formats and resolutions, such as 1x and 2x, along with a fallback image. Create src/lib/components/Image.svelte to render it:
<script lang="ts">
import type { Picture } from "@sveltejs/enhanced-img";
let {
src,
alt,
}: {
src: Picture;
alt: string;
} = $props();
</script>
<picture>
{#each Object.entries(src.sources) as [format, srcset]}
<source type={`image/${format}`} {srcset} />
{/each}
<img src={src.img.src} width={src.img.w} height={src.img.h} {alt} />
</picture>Scripts and External Components
Markdown pages may need an instance-level <script> block—for example, to import another Svelte component. With raw HTML enabled, Markdown Exit can preserve that block alongside the module-level script generated by the preprocessor.
Svelte component tags can likewise pass through the Markdown renderer and remain in the generated Svelte source. If a construct is not preserved correctly, add a dedicated parser rule or move the construct into a separate .svelte component and import that component instead.
Enable raw HTML input in Markdown Exit so that these tags can pass through:
md.options.html = true;Svelte permits at most one instance script and one module script per component. If you want to write multiple script blocks, the preprocessor must extract and merge compatible blocks before Markdown rendering. A regular expression is reasonable, or use a proper parser if you need to support arbitrary Svelte scripts and attributes.
Extend Markdown Syntax
If you need richer Markdown constructs, such as an alert block:
> [!info]
>
> xxxTake a look at the project markdown-it plugins.
There's one caveat. Although Markdown Exit says it is compatible with markdown-it plugins, its TypeScript types are not currently a drop-in replacement for markdown-it's:
types incompatible with markdown-itYou may need to download a plugin's source code and adapt it for Markdown Exit yourself. The changes are usually small enough to make by hand or with AI assistance. And because markdown-it plugins are designed for general use, adapting one gives you a chance to remove anything your blog doesn't need.
Content Management
So far, +page.md has been a handy test file. A real blog is easier to manage when its posts live in a dedicated content directory:
With this setup, keep your posts under src, or configure Vite's server.fs.allow option so it can include them in its module graph.
Otherwise, you'll likely see this error:
The request id xxx is outside of Vite serving allow list.
See the documentation for server.fs.allow configuration details.
Load Posts with a Dynamic Route
We can now use a dynamic route and generate an entry for each post:
In +page.server.ts, import.meta.glob discovers all posts, and entries tells SvelteKit which values to prerender for [slug]. Object.keys(postModules) returns paths such as /src/posts/my-post/index.md; path.split("/").at(-2)! extracts my-post:
import type { EntryGenerator } from "./$types";
const postModules = import.meta.glob("/src/posts/*/index.md");
export const entries: EntryGenerator = () => {
return Object.keys(postModules)
.map((path) => path.split("/").at(-2)!)
.map((slug) => ({ slug }));
};This extraction assumes one directory level per slug. If you later support nested slugs, derive all path segments between /src/posts/ and /index.md instead.
In +page.ts, select and load the post that matches the current slug. Because the glob is not eager, Vite generates one loader per post; at runtime, the load function invokes only the loader for the requested slug:
import { error } from "@sveltejs/kit";
import type { Component } from "svelte";
import type { PageLoad } from "./$types";
interface MarkdownModule {
default: Component;
}
const postModules = import.meta.glob<MarkdownModule>("/src/posts/*/index.md");
export const load: PageLoad = async ({ params }) => {
const { slug } = params;
const loadPost = postModules[`/src/posts/${slug}/index.md`];
if (!loadPost) {
error(404, "Post not found");
}
try {
const page = await loadPost();
return { default: page.default };
} catch (cause) {
if (import.meta.env.DEV) {
console.error(cause);
}
error(500, "Failed to load post");
}
};Finally, render the generated Svelte component from +page.svelte:
<script lang="ts">
import type { PageData } from "./$types";
let { data }: { data: PageData } = $props();
</script>
<data.default />Metadata and Frontmatter
Blog posts usually need frontmatter for metadata:
---
title: My Post
description: Lorem ipsum dolor sit amet duo et no.
---
Post content.Install the yaml package as a development dependency. If you're using Bun, you can use Bun.YAML.parse() instead.
Then create src/preprocess/frontmatter.ts. This helper separates the opening frontmatter block and parses it as YAML:
import { parse as parseYaml } from "yaml";
export interface PostMetadata {
[key: string]: unknown;
title: string;
description: string;
}
export function extractFrontmatter(content: string): {
body: string;
metadata: PostMetadata;
} {
const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
if (!match) {
throw new Error("Post is missing a frontmatter block");
}
const value: unknown = parseYaml(match[1]);
return {
body: content.slice(match[0].length),
metadata: value as PostMetadata,
};
}Extract the frontmatter before converting the Markdown body into tokens. The following snippet replaces processMD; keep the Markdown Exit setup, rule registrations, MarkdownEnv, and toVitePath from the earlier sections:
import { extractFrontmatter } from "./frontmatter.ts";
export async function processMD(content: string, filename: string): Promise<string> {
const env: MarkdownEnv = {
sourcePath: toVitePath(filename),
moduleStatements: [],
assetId: 0,
};
const { body, metadata } = extractFrontmatter(content);
const tokens = md.parse(body, env);
const markup = await md.renderer.renderAsync(tokens, md.options, env);
const script = `
<script lang="ts" module>
import Paragraph from "$lib/components/Paragraph.svelte";
import Fence from "$lib/components/Fence.svelte";
import Image from "$lib/components/Image.svelte";
export const metadata = ${JSON.stringify(metadata)};
${env.moduleStatements.join("\n")}
</script>
`;
return script + markup;
}The generated module now exports metadata, so update the module type used by import.meta.glob:
interface PostMetadata {
title: string;
description: string;
}
interface MarkdownModule {
default: Component;
metadata: PostMetadata;
}
const postModules = import.meta.glob<MarkdownModule>("/src/posts/*/index.md");Return the metadata alongside the component from +page.ts:
const page = await loadPost();
return {
default: page.default,
metadata: page.metadata,
};You can also read the exported metadata when building index and category pages.
Discovery and Syndication
In the post route's +page.svelte, use the returned metadata to populate the document head:
<svelte:head>
<title>{data.metadata.title}</title>
<meta name="description" content={data.metadata.description} />
</svelte:head>A sitemap helps search engines discover the pages on your site. I recommend giving super-sitemap a try.
The package discovers static routes automatically. For a dynamic route such as /post/[slug], you should provide the possible parameter values:
import * as sitemap from "super-sitemap/sveltekit";
import type { RequestHandler } from "@sveltejs/kit";
const postModules = import.meta.glob("/src/posts/*/index.md");
const postSlugs = Object.keys(postModules).map((path) => path.split("/").at(-2)!);
export const prerender = true;
export const GET: RequestHandler = async () => {
return sitemap.response({
origin: "https://example.com",
paramValues: { "/post/[slug]": postSlugs },
});
};If you set trailingSlash = "always" or your server redirects requests to URLs with trailing slashes, make sure your sitemap uses the same format.
super-sitemap does not automatically apply SvelteKit's trailingSlash setting. For example, it generates /post/my-post instead of /post/my-post/. Your sitemap should list the canonical URLs you want indexed, rather than URLs that redirect to them.
To add trailing slashes, pass a processPaths callback to sitemap.response:
export const GET: RequestHandler = async () => {
return sitemap.response({
origin: "https://example.com",
paramValues: { "/post/[slug]": postSlugs },
processPaths(paths) {
return paths.map((entry) =>
entry.path.endsWith("/") ? entry : { ...entry, path: `${entry.path}/` },
);
},
});
};If you'd like to offer an RSS feed, take a look at node-rss. The library is written in JavaScript, but if you'd prefer a small TypeScript implementation, it's easy to recreate by hand or with AI assistance. A custom version also lets you leave out features your blog doesn't need.
Deploy
Before building, add the following page options to src/routes/+layout.ts so that SvelteKit prerenders the site. trailingSlash = "always" produces directory-style output such as post/my-post/index.html, which works on static hosts that do not map /post/my-post to post/my-post.html:
export const prerender = true;
export const trailingSlash = "always";We already added adapter-static to the project, so a single build command will generate the static site:
bun run buildBy default, the adapter writes the output to build. Deploy the contents of that directory to your static host.
Your host must return a real 404 Not Found status when someone requests a page that doesn't exist. If it returns 200 OK, crawlers may treat the missing URL as valid, and SvelteKit's fallback page may display Internal Error instead of Not Found.
If you use Salvo, keep in mind that with StaticDir configured like this, Salvo returns 200 OK instead of 404 Not Found when a request doesn't match a file and StaticDir falls back to 404.html.
StaticDir::new(["my-blog"])
.defaults("index.html")
.fallback("404.html")As a result, SvelteKit may display the wrong error, and SEO diagnostics may report that the server does not return a 404 status correctly.
And that's the core pipeline behind my blog. From here, styling, navigation, and further production hardening are natural next steps.

Thanks for reading! This post may be a little plain, but I hope it gives you a solid foundation for building your own blog—and that you can adapt these ideas to fit your own workflow.
Comments