Build The Perfect Tech Blog With SvelteKit

blog banner

Published at Oct 1, 2024
Total Views: 0

In this tutorial, we’ll build a fully-functional, SEO-friendly blog using SvelteKit, Tailwind CSS, and Shiki for beautiful syntax highlighting. We’ll also use mdsvex for markdown support, remark-unwrap-images and remark-toc for handling images and generating table of contents, and rehype-slug for clean URL slugs.

By the end of this tutorial, you’ll have a responsive blog with markdown support, code highlighting, and metadata display. Let’s dive in step-by-step!


Step 1: Setting Up SvelteKit and Tailwind CSS

1.1 Install SvelteKit

To get started, we first need to set up a SvelteKit project. Run the following commands to initialize a new project:

npm create svelte@latest my-blog
cd my-blog
npm install

1.2 Install Tailwind CSS

Next, let’s integrate Tailwind CSS for styling. Tailwind is perfect for building responsive and aesthetically pleasing layouts for blogs.

npm install -D tailwindcss postcss autoprefixer @tailwindcss/typogrophy
npx tailwindcss init tailwind.config.cjs -p

Update your tailwind.config.cjs file to point to the content paths for Tailwind to scan for utility classes:

module.exports = {
	content: ["./src/**/*.{html,js,svelte,ts}"],
	theme: {
		extend: {},
	},
	plugins: [require("@tailwindcss/typography")], // Add typography support for better blog post styling
}

In your src/app.css, import Tailwind’s base styles:

@tailwind base;
@tailwind components;
@tailwind utilities;

Then let’s update our src/routes/+page.svelte:

<h1>Welcome to My SvelteKit Blog!</h1>
<a class="text-blue-500" href="/blog">View my blogs!</a>

Finally, include this CSS file in your src/routes/+layout.svelte:

<script lang="ts">import "../app.css";
</script>

<main class="container mx-auto h-full">
	<slot />
</main>

Now, you’ve got Tailwind ready for responsive styling!


Step 2: Setting Up Markdown Parsing with mdsvex and Shiki

We’ll use mdsvex to parse markdown files and Shiki for beautiful syntax highlighting.

2.1 Install Required Dependencies

Install all necessary packages for markdown parsing and syntax highlighting:

npm install mdsvex shiki remark-unwrap-images remark-toc rehype-slug

2.2 Configure mdsvex and SvelteKit

Here’s the full svelte.config.js setup to enable mdsvex, Shiki, and plugins like remark-unwrap-images and remark-toc.

import adapter from "@sveltejs/adapter-auto"
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"
import { mdsvex, escapeSvelte } from "mdsvex"
import { bundledLanguages, getSingletonHighlighter } from "shiki"
import remarkUnwrapImages from "remark-unwrap-images"
import remarkToc from "remark-toc"
import rehypeSlug from "rehype-slug"

/** @type {import('mdsvex').MdsvexOptions} */
const mdsvexOptions = {
	extensions: [".md"],
	highlight: {
		highlighter: async (code, lang = "text") => {
			const highlighter = await getSingletonHighlighter({
				themes: ["one-dark-pro"],
				langs: Object.keys(bundledLanguages),
			})
			const html = escapeSvelte(highlighter.codeToHtml(code, { lang, theme: "one-dark-pro" }))
			return `{@html `${html}` }`
		},
	},
	remarkPlugins: [remarkUnwrapImages, [remarkToc, { tight: true }]],
	rehypePlugins: [rehypeSlug],
}

export default {
	extensions: [".svelte", ".md"],
	preprocess: [vitePreprocess(), mdsvex(mdsvexOptions)],
	kit: {
		adapter: adapter(),
	},
}

This setup enables markdown (.md) support and syntax highlighting with the One Dark Pro theme from Shiki.

Note: I’m using getSingletonHighlighter and not getHighlighter. When your statically generating your site, getHighlighter will create a new instance of itself for each codeblock in every blog and they will not be deleted. This causes a huge preformace issue when it comes to building your site. When I switched to getSingletonHighlighter my 20 blog build time went from 3 minutes to 1 minute!


Step 3: Creating Blog Posts in Markdown

Each blog post is written as a markdown file in the src/posts directory. Here’s how a typical post might look:

Example Post: src/posts/first-post.md

---
title: "First Post"
description: "This is my first post!"
date: "2024-09-24"
image: /first-post-banner.webp
categories:
  - blog
  - tutorial
published: true
---

## Contents

## Intro

This is my first post!

## Let's get started

Lorem ipsum dolor sit amet...

This metadata in the frontmatter helps populate information like the title, description, date, and categories for each post. Important, to make sure images are working, please add an image in your /static folder named first-post-banner.webp so you can verify it works and nothing breaks.

Also, you may have nocticed the ## Contents here. That’s where remark-toc comes in! It will take that ## Contents and generate and actual table of contents (toc).


Step 4: Fetching and Displaying Blog Posts

We’ll now create an API route to fetch blog posts and display them in a list. But before we move on, let’s add a type for our Post to the app.d.ts for anyone following along in typescript:

// ...
interface Post {
	title: string
	slug: string
	description: string
	image?: string
	date: string
	categories: string[]
	published: boolean
}

4.1 Fetching Blog Posts

Create a server route src/routes/api/posts/+server.ts to load the markdown files:

import { json } from "@sveltejs/kit"

async function getPosts() {
	let posts: Post[] = []

	const paths = import.meta.glob("/src/posts/*.md", { eager: true })

	for (const path in paths) {
		const file = paths[path]
		const slug = path.split("/").at(-1)?.replace(".md", "")

		if (file && typeof file === "object" && "metadata" in file && slug) {
			const metadata = file.metadata as Omit<Post, "slug">
			const post = { ...metadata, slug } satisfies Post
			if (post.published) {
				posts.push(post)
			}
		}
	}

	posts = posts.sort(
		(first, second) => new Date(second.date).getTime() - new Date(first.date).getTime()
	)

	return posts
}

export async function GET() {
	const posts = await getPosts()
	return json(posts)
}

export const prerender = true

This function grabs all markdown files from the src/posts directory, extracts metadata, and returns an array of posts.

Why We’re Using export const prerender = true

So you might be wondering, “What’s up with the export const prerender = true in the blog post API route?” Well, here’s the deal—prerendering is one of the key benefits of using SvelteKit. By setting prerender = true, we’re telling SvelteKit to generate the HTML for this page at build time rather than run time.

This has several major benefits for our blog:

  1. Improved Performance: Since the HTML is generated ahead of time, when users hit our blog, they’re getting static files served right away instead of waiting for the server to dynamically build the page. This results in faster load times and a smoother user experience.

  2. Better SEO: Prerendering also makes the blog more search engine friendly. Search engines like Google can easily crawl static pages, ensuring that all the important metadata (like blog titles, descriptions, etc.) is captured and ranked.

  3. Reduced Server Load: Because the pages are prebuilt, there’s no need to hit the server each time someone visits a blog post. This reduces the load on your server, saving you bandwidth and making your site more scalable—especially useful if your blog goes viral. 🙌

In short, prerendering helps make your blog faster, more efficient, and more SEO-friendly—exactly what you want when you’re building a modern blog platform.

So, if you’re curious about how to squeeze the most out of your blog, prerendering is one way to do it!

4.2 Displaying Posts in a Blog Page

Next, let’s build a page that lists all the blog posts. Create src/routes/blog/+page.server.ts to load posts:

import type { ServerLoadEvent } from "@sveltejs/kit"

export async function load({ fetch }: ServerLoadEvent) {
	const response = await fetch("/api/posts")
	const posts: Post[] = await response.json()
	return { posts }
}

4.3 Building the Blog Page Layout

We’ll display each blog post using a custom BlogCard component. Let’s create src/lib/components/BlogCard.svelte:

<script lang="ts">import { formatDate } from "$lib/utils.js";
import { title, description, url } from "$lib/config";
export let post;
</script>

<svelte:head>
	<title>{title}</title>

	<meta name="description" content={description} />

	<meta property="og:type" content="article" />
	<meta property="og:url" content={`${url}/blog`} />
	<meta property="og:title" content={title} />
	<meta property="og:description" content={description} />
	<meta property="og:site_name" content={title} />
	<meta property="og:image" content="/blog-banner.webp" />

	<meta name="twitter:site" content="@McBride1105" />
	<meta name="twitter:creator" content="@McBride1105" />
	<meta name="twitter:title" content={title} />
	<meta name="twitter:description" content={description} />
	<meta name="twitter:card" content="summary_large_image" />
	<meta name="twitter:image:src" content="/blog-banner.webp" />
	<meta name="twitter:widgets:new-embed-design" content="on" />

	<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
	<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
</svelte:head>

{#key post.slug}
	<a class="card card-hover overflow-hidden w-full max-w-4xl mt-4 mx-4" href={`/blog/${post.slug}`}>
		<header class="mb-4">
			{#if post.image}
				<img src={post.image} alt="blog banner" />
			{/if}
		</header>

		<div class="p-4 space-y-4">
			<h3 class="h3" data-toc-ignore>{post.title}</h3>
			<article>
				<p>
					{post.description}
				</p>
			</article>
		</div>
		<hr class="opacity-50" />
		<footer class="p-4 flex justify-start items-center space-x-4">
			<div class="flex-auto flex justify-between items-center">
				<h6 class="font-bold" data-toc-ignore>By Jimmy McBride</h6>
				<small>On {formatDate(post.date)}</small>
			</div>
		</footer>
	</a>
{/key}

4.4 Adding Format Date Util And Metadata Config

For our formatDate util we’ll add src/lib/utils.ts:

type DateStyle = Intl.DateTimeFormatOptions["dateStyle"]

export function formatDate(date: string, dateStyle: DateStyle = "medium", locales = "en") {
	// Safari is mad about dashes in the date
	const dateToFormat = new Date(date.replaceAll("-", "/"))
	const dateFormatter = new Intl.DateTimeFormat(locales, { dateStyle })
	return dateFormatter.format(dateToFormat)
}

Finally, lets add our config file for some basic metadata information src/lib/config.ts:

import { dev } from "$app/environment"
export const title = "Your Website's Title"
export const description = "A description of your website."
export const url = dev ? "http://localhost:5173" : "https://yourproductionwebsite.com"

4.5 Using The BlogCard

Now we can use that component for our blog page src/routes/blog/+page.svelte:

<script lang="ts">import BlogCard from "$lib/components/BlogCard.svelte";
export let data;
</script>

<section class="mb-16">
	<ul class="flex flex-col items-center">
		{#each data.posts as post}
			<BlogCard {post} />
		{/each}
	</ul>
</section>

Now we should be about to a list of all our blogs!


Step 5: Displaying Individual Blog Posts

5.1 Fetching a Single Post by Slug

We need to create a [slug] route to display individual blog posts. Create the file src/routes/blog/[slug]/+page.ts:

import { error } from "@sveltejs/kit"
import type { ServerLoadEvent } from "@sveltejs/kit"

export const load = async ({ params }: ServerLoadEvent) => {
	try {
		const post = await import(`../../../posts/${params.slug}.md`)

		return {
			content: post.default,
			meta: post.metadata,
		}
	} catch (e) {
		throw error(404, `Could not find ${params.slug}`)
	}
}

5.2 Rendering the Blog Post

Finally, here’s how we render the full post content in +page.svelte:

<script lang="ts">import { formatDate } from "$lib/utils";
import { url, title } from "$lib/config";
export let data;
</script>

<!-- SEO -->
<svelte:head>
	<title>{data.meta.title}</title>

	<link rel="canonical" href={`${url}${data.url}`} />
	<meta name="description" content={data.meta.description} />

	<meta property="og:type" content="article" />
	<meta property="og:url" content={`${url}${data.url}`} />
	<meta property="og:title" content={data.meta.title} />
	<meta property="og:description" content={data.meta.description} />
	<meta property="og:site_name" content={title} />
	<meta property="og:image" content={data.meta.image} />

	<meta name="twitter:site" content="@YouTwitterHandle" />
	<meta name="twitter:creator" content="@YouTwitterHandle" />
	<meta name="twitter:title" content={data.meta.title} />
	<meta name="twitter:description" content={data.meta.description} />
	<meta name="twitter:card" content="summary_large_image" />
	<meta name="twitter:image:src" content={data.meta.image} />
	<meta name="twitter:widgets:new-embed-design" content="on" />

	<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
	<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)" />
</svelte:head>

<article class="prose mb-16 mx-auto">
	<!-- Title -->
	<hgroup class="flex flex-col items-center">
		<h1 class="">{data.meta.title}</h1>
		<img src={data.meta.image} alt="blog banner" class="rounded-md" />
		<p class="text-end text-sm">
			Published at {formatDate(data.meta.date)}
		</p>
	</hgroup>

	<!-- Tags -->
	<div class="flex flex-wrap gap-4 mb-6">
		{#each data.meta.categories as category}
			<a href={`/blog/categories/${category}`} class="chip variant-filled-secondary no-underline"
				>&num;{category}</a
			>
		{/each}
	</div>

	<!-- Post -->
	<svelte:component this={data.content} />
</article>

Conclusion

And there you have it! A fully functional, SEO-optimized blog built with SvelteKit, Tailwind CSS, mdsvex, and Shiki. You now have the tools to write markdown-based posts with automatic syntax highlighting, responsive designs, and metadata for improved search engine performance. Whether you’re sharing tutorials, documenting projects, or blogging about your passion, this setup has you covered for a modern web experience.

If you’re as excited about creating awesome content as I am and want to hang out with other like-minded developers, feel free to join us over in The Developers Lounge. We’ve got a great community of coders who love to share, tinker, and help each other out. Hope to see you there! Discord Link.

Comments

No comments yet.

You must be logged in to add a comment.