First post. Setting up a blog.

How to set up a blog from scratch using SvelteKit + Tailwind + MDsveX, and then host it on Cloudflare

NOTE: To set some expectations. This post is a draft-like ramble of dev notes I wrote while setting up this blog. Since the code has evolved a lot already in a few days, I would not recommend trying to follow this post point to point. I will, however, leave it here for future reference.

Introduction

Needed a place to publish stuff. Dev related mostly. But also marketing, from a dev perspective.

Decided to go with SvelteKit. Gives me enough flexibility and control. Best yet, can tinker around with it instead of creating content. (jk)

But realistically, why? Because of flexibility and control. And since I’m a dev—I just can’t help myself but create everything from scratch and reinvent the wheel as I go. Good enough?

Set up SvelteKit + addons

Set up was easy enough:

npx svelte-add

Nothing fancy needed. Chose to add Tailwind CSS and mdsvex. No typescript, no linting, no nonsense.

I know. There are ready made solutions. Pay $ get blog. Or pick a free template get blog. Don’t care. I like reinventing the wheel, to an extent.

But this setup makes it easy. Super easy. And I don’t have to give up control or fiddle with stuff I don’t need.

Say YES to Tailwind CSS typography plugin. And let’s go with npm as the package manager, for now at least.

You’re all set! 🎉 Yay. Well, almost.

Before doing any changes to the project, I like to run git init. That way I’ll know what all I’ve changed myself. This can be useful when setting up a new project down the line, to see what all little tweaks you had to apply to get things actually working.

git init
git add .
git commit -m "initial import"

I need code blocks

Since I’m going to focus on dev, I’m likely to share some code. Code blocks in markdown work like this:

// greatest piece of code on earth

const lol = "foobar"
const fn = (arr) => arr.map(el => el.replace("xx", "yy"))

function asd() {
  console.log("hello world")
}''

asd()

// damn the naming tho

But I also want to have syntax highlighting. Turnsout mdsvex does this almost out of the box. Just Bring Your Own Styles.

While Googling around, I found this post: https://www.alexandersix.com/posts/syntax-highlighting-with-sveltekit

In a nutshell: Go to https://github.com/PrismJS/prism-themes/blob/master/themes/prism-gruvbox-dark.css and steal one of the css files from there. Or just copy paste the styles. And add them to the project. Aaand we have syntax highlighting for the Greatest Piece Of Code On Earth above. Yay²

Let’s get it running

Since I’m using docker for dev, we’ll need to add —host 0.0.0.0 in package.json after dev command. You don’t need to do this if you’re not using docker. In fact, you probably should not do this unless using docker. Docker dev setup is worth its own post. Hold me accountable. @mikkokaar

And oh, I like nano. Huge fan. Actually used to code with it back in the day and got substantial shit for that. But I still love it.

Why? nano xx opens up a file for editing. ctrl+x saves and closes the file. Could not be simpler than that. As said, love it.

Now let’s browse to localhost:5173 and lo and behold, our cool new site is up. Yay!

Kinda ugly though. Welcome to SvelteKit on blank page. But at least no spinning react logos and other crap. Blank slate (with some text). I like it.

Let’s do some layouting

I’ll want to keep my blog simple. Maybe because I suck at graphic design. Best way around that handicap is to keep it all very simple.

A black background, white text, this should be enough to turn off at least half the potential readers who like black text on white background. Let’s go for it.

In src/routes/+layout.svelte lets add some code to contain whatever ramblings it shall contain in the future.

<script>import "../app.css";</script>

<div class="min-h-screen w-full bg-black text-white flex items-center">
  <div class="container mx-auto font-sans py-8 px-8 flex justify-center">
    
    <div class="w-full md:max-w-2xl lg:max-w-3xl">

      <header class="mb-8 text-pink-500">
        <a href="/">mikkokaar.dev</a>
      </header>

      <div class="prose prose-stone dark:prose-invert prose-lg !max-w-full">
        <slot></slot>
      </div>

    </div>
      
  </div>
</div>

There are some important parts here

  • The two outer most divs just align stuff so it’s centered on the screen. Good enough.
  • article has the prose classes, which are related to Tailwind CSS typography. You see, tailwind by default removes all formatting so you can apply your own. And that’s a pain in the butt so they created this plugin that brings it all back. Very nice, well done. 👍
  • Since we’re working on the layout, <slot /> is what will hold our content from routes

Let’s save the layout. Way better immediately! That said, I do lack the Graphic Designer Eye. Good enough for me then.

Let’s add our first post

So I’ve been writing this for a while now without saving, while I did the set up for the project. Maybe now is a good time to save THIS file?

But first, let’s add a folder named [slug] under src/routes

Then add +page.svelte and +page.js under src/routes/[slug]

And add a folder posts under src/routes/[slug] as well. I want to be able to write posts as individual markdown files. And possibly, later on, migrate them to a database. But a small set of files shall do as our database for now.

And now, NOW the time has come! I can FINALLY save this file as right-first-post.md! LFG!

Ah. Now it is saved. Kivi putosi sydämeltä. Or a stone rolled off my heart, or whatever, as we use to say here in Finland. A weight off one’s shoulders probably the closest in English. But I digress.

How do we get this page to load?

http://localhost:5173/right-first-post gives pretty much an empty page.

Let’s fix that. And load the page.

posts/right-first-post.svx

---
title: First post!
description: The very first post
published: 2024-08-07
---

Here it is!

## Table test

Got some random example table data from ChatGPT:

| Order ID | Customer Name | Product     | Quantity | Price | Total |
|----------|---------------|-------------|----------|-------|-------|
| 001      | John Doe      | Widget A    | 3        | $10   | $30   |
| 002      | Jane Smith    | Widget B    | 1        | $20   | $20   |
| 003      | Bob Johnson   | Widget A    | 2        | $10   | $20   |
| 004      | Alice Brown   | Widget C    | 5        | $15   | $75   |
| 005      | Charlie White | Widget B    | 4        | $20   | $80   |

Now. We have our first post file created — How do we load it?

I meant to write it all here. But. Got distracted. Scroll down a little and you’ll see the code. :-) Or click here: https://mikkokaar.dev/setting-up-a-blog#full-code-kinda

Fix code syntax highlighting

As mentioned above, go steal this file https://github.com/PrismJS/prism-themes/blob/master/themes/prism-gruvbox-dark.css (or any of its siblings) and add it under src/routes/[slug]

Then, add this line in the script in src/routse/[slug]/+page.svelte to load the styles:

import "./prism-gruvbox-dark.css";

And voilà! Is fixed.

// see? now it works.
const blah = () => crashandburn()

Let’s work on the front page a little

Open up src/routes/+page.svelte and rename it to +page.svx (folder stays the same) so that it’ll get parsed my mdsvex

Replace the file contents with this:

# Welcome to the Greatest Blog On Earth (I wish!)

How would we list our blog posts on the front page?

Let’s see. Let’s move them a little first.

mv src/routes/[slug]/posts src/posts

Well. Arf. Did some refactoring :-) Sorry for losing you for a while. Let’s continue.

Full code. Kinda.

src/lib/posts.js:

export const getSlugMap = async () => {
  const slugMap = {}

  const files = import.meta.glob("../posts/*.{md,svx}")

  for (const [path, load] of Object.entries(files)) {
    const slug = path.replace(/^.*/(.*).(md|svx)$/, '$1')

    slugMap[slug] = {
      slug,
      load
    }
  }

  return slugMap
}

export const getPostBySlug = async slug => {
  const slugMap = await getSlugMap()
  const stub = slugMap[slug]
  return load(stub)
}

export const getAllPosts = async () => {
  const slugMap = await getSlugMap()
  
  const posts = []

  for (const stub of Object.values(slugMap)) {
    //console.log("stub", stub)
    posts.push(await load(stub))
  }

  posts.sort((a,b) => {
    let ad = new Date(a.date).getTime()
    let bd = new Date(b.date).getTime()
    return ad-bd
  }).reverse()

  //console.log({ posts })

  return posts
}


const load = async stub => {
  const slug = stub.slug

  const post = await stub.load()
  const metadata = await post.metadata
  const component = post.default

  const formatDate = date => {
    console.log("date", date)
    if (!date) return ""
    const offset = date.getTimezoneOffset()
    return new Date(date.getTime() - (offset*60*1000)).toISOString().split('T')[0]
  }

  const datePublished = metadata.published && new Date(metadata.published)
  const dateUpdated = metadata.updated && new Date(metadata.updated)

  const sortDate = dateUpdated ? dateUpdated : datePublished

  return {
    slug,
    metadata,
    title: metadata.title,
    published: formatDate(datePublished),
    updated: formatDate(dateUpdated),
    sortDate,
    component,
  }
}

src/routes/[slug]/+page.js:

import { getPostBySlug } from "$lib/posts"

export const load = async ({ params }) => {
  const post = await getPostBySlug(params.slug)

  return {
    post
  }
}

src/routes/[slug]/+page.svelte:

<script>
  import "./prism-gruvbox-dark.css";
  export let data;
  
  const post = data.post
</script>

<h1>{post.title}</h1>
<!--<div class="text-sm">Published {post.published}</div>-->

<svelte:component this={post.component} />

Remember, get https://github.com/PrismJS/prism-themes/blob/master/themes/prism-gruvbox-dark.css and add it in the same [slug] directory.

src/routes/+page.js:

import { getAllPosts } from "$lib/posts"

export const load = async () => {
  const posts = await getAllPosts()

  return {
    posts
  }
}

src/routes/+page.svx:

<script>
  export let data;
  const posts = data.posts;
</script>

# Welcome to the Greatest Blog On Earth (I wish!)


{#each posts as post}
  <div>
    <a href="/{post.slug}">
      {post.published}
      {post.title}
    </a>
  </div>
{/each}

With all that, we now have a Working Markdown Enabled SvelteKit Blog Site. You’re welcome.

What’s left?

Some actions left we need to take. Namely, we’ll want to ensure that SEO tags are in place and we need to deploy our little blog somewhere.

Let’s do basic SEO tags first.

Adding tags for SEO

Ahrefs is the absolute best source for anything SEO, imho. So let’s follow their guide at https://ahrefs.com/blog/seo-meta-tags/

We’ll need different title and description for each page. We need charset and viewport. That’s it.

Title and description

Let’s add this to src/routes/[slug]/+page.svelte just above the h1 tag:

<svelte:head>
  <title>{post.title}</title>
  <meta name="description" content="{post.description}" />
</svelte:head>

Now all we need to do this add description in our post file. Remember, it’s the block between the dashes at the top where this stuff goes.

Robots tag

Let’s add this one too, so we’ll have control over it if need to:

<meta name=”robots” content="index, follow">

You could change these to noindex and nofollow if needed. Or, for example index, nofollow if you don’t want to help rank the pages you link to.

Charset and viewport

These are the default tags. No need to tweak them. Add them directly in src/app.html since they’re likely to be the same for each and every page.

<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

Oh, SvelteKit already added them for us. By default. Nice. So nevermind, and let’s move on.

Btw. No idea if there’s a difference between “utf-8” and “UTF-8”. Anyone?

Keywords

If you read the Ahrefs article you’ll see they advice against stuffing keywords in a meta tag. So let’s heed that advice, at least, to avoid getting penalized by these lovely search giants. We’ll want this page to rank, right?

Deploy

Now that our page ranks first on Google even before we’ve deployed it, due to all this SEO wizardry, we might actually want to deploy it.

To set some expectations right though: We’ll probably rank for nothing and the page will get zero clicks and zero impressions. But that’s life. 🥲

Deploy, for real

To deploy our sh*t, we’re going to use Cloudflare. Why? Because I have sites running there already so it is easy for me. You’re just collateral damage.

And no, these pages have no visitors, since I suck at SEO. And maybe because they had no content. But I digress.

To deploy to Cloudflare you will need to sign up for a Cloudflare account obviously. Go do it.

Then…

  • Go to your Cloudflare dashboard at https://dash.cloudflare.com
  • Under Workers & Pages choose ‘create’
  • Select the tab ‘Pages’
  • Select ‘Connect to Git’

Well, now I need to go and set up the git repo @ github, commit our code created thus far, and push it all to that repo. Brb.

Btw I have a separate account at Github for my websites. I like the separation. Though sometimes it’s a pain.

I like to keep my repos private. If you do too, remember to choose Private when you create a new repo for your blog.

Also you will need to connect Cloudflare and Github, and grant Cloudflare access to your repos. I’m sure you’ll manage.

Don’t add a readme since we already have a repo we want to import. No need for .gitignore or a license file either, for now.

Once you’ve created the empty repository, follow github’s instructions for pushing an existing repository from the command line; git remote add origin etc.

Commit everything and push.

Go back to Cloudflare, and if you can’t see the repo yet, click the link under the list of repositories where it says “If your repository is not shown, configure repository access for the Cloudflare Pages app on GitHub.” This especially, if you only want to grant Cloudflare access to certain repositories only. This link will take you to Github, where you can give Cloudflare access to the repository.

Then go and add the Pages project again.

  • Choose the repository
  • Production branch will most likely be ‘main’ unless you did anything weird, so that’s good.
  • Choose SvelteKit from the Framework preset dropdown, and that will populate the build command & build output directory settings for you
  • Click Save & Deploy, cross your fingers 🤞 and hope for the best

Success! Your project is deployed to Region: Earth

I wonder what the other regions are?

If you get DNS_PROBE_FINISHED_NXDOMAIN (or other DNS error) give Cloudflare a little time. Then reload. A minute or two was enough in my case.

Connect domain name

  • In Cloudflare, click the tab “Custom domains”, then click Set up a custom domain
  • For my site, I’ll type in mikkokaar.dev as the Domain. I already set up my domain with Cloudflare earlier. If you dont have a custom domain, worry not, you can set it up later.
  • Cloudflare will add a CNAME in the DNS zone in a very straightforward way

And a few minutes later this blog is now up at https://mikkokaar.dev 🤯 I’m actually surprised I did not run into any major problem this time.

How to draft posts?

Draft them locally, but don’t commit them. Simple as that. We could add a separate flag and logic for unpublishing posts later on.

For example, add could draft: false at the top of the post, then add logic in lib/posts.js to filter those posts out.

What’s left?

  • Generate a sitemap
  • Add analytics
  • Add all the code so far to a public repo for you to look at

Let’s do analytics.

I recently signed up for https://plausible.io so we’ll go with that.

Extremely easy. Add the site in plausible. Copy the snippet. Add it to head in src/app.html. Git add, commit and push. Wait for site to deploy. Click verify again in Plausible. Done.

Generate a sitemap

Ouch. Yikes. Hmm. Chatgpt to the rescue…

Let’s ask it this:

Dude. I got an array of blog posts. How'd you go about generating a sitemap for it in js? Give me as simple and short code as possible. Code only. No blah blah.

Here’s what we got back:

function generateSitemap(posts) {
    const baseUrl = 'https://yourwebsite.com';
    const urls = posts.map(post => `<url><loc>${baseUrl}/${post.slug}</loc></url>`);
    return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;
}

const blogPosts = [
    { slug: 'post-1' },
    { slug: 'post-2' },
    { slug: 'post-3' }
];

console.log(generateSitemap(blogPosts));

LGTM eventhough I didn’t really read it. So let’s go with that.

But wait. We need to access it somehow. I know. Let’s ask ChatGPT!

Me: ok how’d i add this in sveltekit project? code only

// src/routes/sitemap.xml/+server.js

export const GET = () => {
    const blogPosts = [
        { slug: 'post-1' },
        { slug: 'post-2' },
        { slug: 'post-3' }
    ];

    const baseUrl = 'https://yourwebsite.com';
    const urls = blogPosts.map(post => `<url><loc>${baseUrl}/${post.slug}</loc></url>`);
    
    const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

    return new Response(sitemap, {
        headers: {
            'Content-Type': 'application/xml'
        }
    });
};

After saving that, sure enough, there’s a sitemap at http://localhost:5173/sitemap.xml

But wait. Real data? Our posts?

Let’s fix that.

Me:

import { getAllPosts } from "$lib/posts"
this async function will return an array with posts, each post has properties: slug, title, published, description

Robot:

// src/routes/sitemap.xml/+server.js

import { getAllPosts } from '$lib/posts';

export const GET = async () => {
    const blogPosts = await getAllPosts();

    const baseUrl = 'https://yourwebsite.com';
    const urls = blogPosts.map(post => `<url><loc>${baseUrl}/${post.slug}</loc></url>`);

    const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

    return new Response(sitemap, {
        headers: {
            'Content-Type': 'application/xml'
        }
    });
};

Now we notice that the url, obviously, is still wrong. That’s an easy fix.

But more important than that, let’s see if it is actually producing anything sensible.

Sitemap spec can be found here: https://www.sitemaps.org/protocol.html

Immediately we’ll see that … the output is pretty good in fact.

Because this is what we got:

<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url><loc>https://yourwebsite.com/second-post</loc></url>
<url><loc>https://yourwebsite.com/first-post</loc></url>
</urlset>

Now, let’s just add the lastmod in hopes that Google can do something useful with that.

Also, we probably should take care of entity escaping. Don’t need it now, but won’t hurt to ask for that too. The likeliest case would be the url containing an ampersand (&)

Me:

add lastmod (based on post.updated and if that's not available then post.published)
also make sure entites in the url are escaped

And here’s what we end up with, and this seems to work nicely:

// src/routes/sitemap.xml/+server.js

import { getAllPosts } from '$lib/posts';

export const GET = async () => {
  const blogPosts = await getAllPosts();

  const baseUrl = 'https://yourwebsite.com';
  const urls = blogPosts.map(post => {
  const url = `${baseUrl}/${encodeURI(post.slug)}`.replace(/&/g, '&amp;');
  const lastmod = post.updated || post.published;

  return `<url>
  <loc>${url}</loc>
  <lastmod>${lastmod}</lastmod>
</url>`;
  });

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml'
    }
  });
};

We should probably add our website root and any other pages we might add to the sitemap too.

TL;DR sitemap.xml generation code

With some more back and forth with the bot and some editing by hand, we end up with this:

// src/routes/sitemap.xml/+server.js

import { getAllPosts } from '$lib/posts';

const baseUrl = 'https://yourwebsite.com';

export const GET = async () => {
  const blogPosts = await getAllPosts();

  const staticUrls = [
    { loc: `${baseUrl}`, priority: 1.0 },
    //{ loc: `${baseUrl}/about`, priority: 0.8 }
  ];

  const allUrls = staticUrls.concat(
    blogPosts.map(post => ({
      loc: `${baseUrl}/${encodeURI(post.slug)}`.replace(/&/g, '&amp;'),
      lastmod: post.updated || post.published
    }))
  );

  const urlElements = allUrls.map(({ loc, lastmod, priority }) => {
    const parts = [
      `<url>`,
      `  <loc>${loc}</loc>`,
      lastmod ? `  <lastmod>${lastmod}</lastmod>` : '',
      priority ? `  <priority>${priority}</priority>` : '',
      `</url>`
    ];
    return parts.filter(Boolean).join('\n');
  });

  const sitemap = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urlElements.join('\n')}
</urlset>`;

  return new Response(sitemap, {
    headers: {
      'Content-Type': 'application/xml'
    }
  });
};

Good enough. Let’s move on.

But wait. Is there anything else left to do?

I’ll just commit my changes now and see how it turns out deployed!

Nice thing about Cloudflare is you can see new deployments in progress without reloading the Deployments page. Thus, if you have it open in a window, you’ll easily see when the deployment is done. Did I say it already, without reloading? Oh I did. Okay. Well c’mon… been a minute already. What the hell gives! Took like three minutes this time…

Anyway now it is deployed again. And I only needed to commit and push. Very nice.

Claim the domain in Google Search Console

Google search console can be found here: https://search.google.com/search-console

Needed to add the site as a new property, and then verify domain ownership via DNS record. Search Console guides you through this nicely. When you click a button, Google actually throws you to Cloudflare, asks for a one-time authorization to add a TXT record, and you just click Authorize. That’s it. Easy. Then you wait a minute (again) and…

Ownership verified!

Yay! Ok. Let’s add it to Ahrefs too. For that, I’ll just login to ahrefs.com and add a new project.

Immediately we get some errors:

  • robots.txt returns http error 500
  • our site is broken and not crawlable (returns 500)
  • www domain does not exist

For some reason http returned error 500, https did not. We only care about https because the domain is .dev

Fix robots.txt

Robots.txt we can add either as a static file, or a dynamic one in case we want to dynamically change it later on:

// src/routes/robots.txt/+server.js

export const GET = () => {
  const robotsTxt = `User-agent: *
Allow: /`;

  return new Response(robotsTxt, {
    headers: {
      'Content-Type': 'text/plain'
    }
  });
};

www domain

Let’s add this as CNAME alias in Cloudflare DNS.

Easiest way to do it is to add as another custom domain under the worker & pages project. Done. Easy.

Ahrefs site verification

This could be done by adding a TXT record to DNS zone.

What’s left?

The site icon is still SvelteKit default icon.

Let’s create something.

Here’s what ChatGPT or DALL-E gave me after I asked it first to describe an icon similar to 🧙 (mage), and then asked it to draw it stating that I would use it as a favicon:

Mage Icon

Again, LGTM out of the box so let’s run with that.

Yesterday I found this awesome tool for creating favicons in Figma: https://www.figma.com/community/file/914233657397286062

And with that we now have this awesome favicon we can use:

Mage Favicon

Let’s add it to head in src/app.html:

<link rel="icon" type="image/png" href="/favicon-196x196.png" sizes="196x196">

Add anchor tags to headers

Found this: https://www.bryanbraun.com/anchorjs/

npm install anchor-js

then add this inside the script tag in src/routes/[slug]/+page.svelte:

import { onMount } from "svelte"
import AnchorJS from "anchor-js"

onMount(() => {
  console.log("add anchors")
  const anchors = new AnchorJS({})
  anchors.add()
})

Final thoughts

Are there any?

This was a post I wrote while setting up what you see right now.

Did you learn anything? Dunno. Did I? Maybe.

Must say though, I like SvelteKit a lot. Also Cloudflare workers & pages (I believe these are about the same thing, almost, these days, so no Idea what to call it) worked very nicely.

This is only the beginning. The very first post. I will, likely, be writing more structured and concise stuff in the future.

https://x.com/mikkokaar