12 min read
Last edited:

This month I decided to make a website for myself. One, that's just right. One, that will stick. One, that I won't abandon like the rest of my side projects... There were many challenges I had to overcome, but, in the end, I finally did it!

In this post I'll just write down my thought processes and decisions. It's not a step-by-step tutorial to creating your own website, but instead a record of a journey. You can view the website's source code as you read through this post, to learn how some stuff works.

Chapter 1: Getting started

Web-design is hard

It took me sooo long to come to this design that you're seeing right now, even though I'm pretty well-versed with HTML, CSS and JS at this point. I did pretty well at my job, developed user interfaces and various interactive graphics, tables and etc. But once I quit, I realized that the reason I did well wasn't experience, — it was because I had direction. There was little creative freedom, — I was just doing whatever I was tasked with, following the provided specifications and clients' requests with reasonable precision. Once in a while, I'd take control and improve some existing stuff, fix small issues and refactor the codebase as needed. The days flew by, and by the time I noticed I had already worked there for almost 2 years.

And now, saddled with the task of describing and drawing up my own wants and needs, I couldn't make much progress for a while. Every time I'd think of something for the website, I would also think of dozens of alternatives, all of which are "worth considering". Luckily, by the time I resolved to make my own website a reality, I had already experimented with a bunch of (very similar, but ever so slightly different) layouts and project structures.

General layout

My main inspiration for the general layout was Docusaurus. Since I already used it for a few static documentation sites, I thought it'd be the easiest for me to adapt into my own website. Many other websites have similar layouts, but I focused mostly on Docusaurus' one.

Chapter 2: Code blocks

Code blocks are an obvious prerequisite, since I'm a programmer and I wanna blog about programming and all things programming-related. So I need these code blocks to be nice, clean, syntax-highlighted, readable and copyable!

Mono-spaced font

First thing — a mono-spaced font. I settled on JetBrains Mono (without ligatures). I like how recognisable and distinct all the characters are. Lowercase characters are a bit larger, but still distinct enough from uppercase. I opted out of ligatures though, — they offset and transform the characters as they're written and it messes with my brain, making it harder to focus on the text.

<svg viewBox="0 0 128 128" height="0.7em" width="0.7em"><path fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" d="M72,56 L72,56c11,11,11,29,0,40l-16,16c-11,11-29,11-40,0l0,0c-11-11-11-29,0-40 l14-14 M56,72 L56,72c-11-11-11-29,0-40l16-16c11-11,29-11,40,0l0,0c11,11,11,29,0,40 l-14,14"/></svg>

Syntax highlighting

For syntax highlighting, at first, I used a prism-react-renderer package, and it worked great, but then I noticed that it's not that great. The fact, that it came prepackaged with a certain set of syntaxes and themes with no way to configure it, bothered me. So, I searched for alternatives, and found refractor, which wraps Prism to output an easy-to-use AST (abstract syntax tree)! It didn't group tokens by lines though, so I had to port the normalizeTokens() function from prism-react-renderer.

The AST made SSR (server-side rendering) easy, but, unfortunately, it looks like Next.js can't code-split dynamically imported modules yet, so the client ends up with a huge mapping of all syntaxes to their modules, regardless of whether they're used only on the server or not. Hopefully that gets fixed in one of the future releases.

<svg viewBox="0 0 128 128" height="0.7em" width="0.7em"><path fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" d="M72,56 L72,56c11,11,11,29,0,40l-16,16c-11,11-29,11-40,0l0,0c-11-11-11-29,0-40 l14-14 M56,72 L56,72c-11-11-11-29,0-40l16-16c11-11,29-11,40,0l0,0c11,11,11,29,0,40 l-14,14"/></svg>

Code block titles

Many documentation websites also title their code blocks, to provide some extra guidance as to where to put the provided code. They kinda look like files, opened in a code editor, and are sometimes accompanied by file/language icons. For that purpose I chose the seti-ui icon set, that's used by VS Code, to bring a feeling of familiarity to the code.

~/source/repos/icons/link.svg
<svg viewBox="0 0 128 128" height="0.7em" width="0.7em"><path fill="none" stroke="currentColor" strokeWidth="10" strokeLinecap="round" d="M72,56 L72,56c11,11,11,29,0,40l-16,16c-11,11-29,11-40,0l0,0c-11-11-11-29,0-40 l14-14 M56,72 L56,72c-11-11-11-29,0-40l16-16c11-11,29-11,40,0l0,0c11,11,11,29,0,40 l-14,14"/></svg>

Line highlighting

Another thing I wanted to implement were line highlighting directives, like // highlight this, <!-- highlight next -->, {/* highlight start */}. They turned out to be surprisingly simple, — just match comments on every line of code, and handle them appropriately.

/Solution/Project/Class.cs
using System;

namespace Project
{
    public sealed class Class : System.Object { }
}

Chapter 3: RSC, SSR and CSR

Server Components are pretty cool. Though It was a tough concept to understand at first, since not many guides explained how it all works internally, but eventually I got the hang of it.

But, as I developed reusable components, I found out that I had to create separate implementations (and therefore add separate exports) for React Server Components and Client Components. That wasn't ideal, because I wanted to put all of the code in one place and have it behave differently depending on what environment it was imported from.

So, I learned a lot of new information about the inner workings of RSC, SSR and CSR, figured out how to differentiate between RSC and SSR environments, and "invented" a solution for this problem, — "Hybrid" Shared Components.

With "Hybrid" Shared Components there's no need to create both /CodeBlock/server and /CodeBlock/client exports. You can import the component from one single path, and have it contain different behaviour, depending on whether they're imported from a Server Component (RSC) or a Client Component ("use client").

Since I couldn't find any info about this technique online, I decided to put this into a separate blog post, so that it hopefully reaches other curious React developers.

See "RSC and Hybrid Shared Components" post.

Chapter 4: Markdown

If I wanna blog on this site, I'll definitely need markdown. In fact, you're already seeing it. But it's not just some regular old Markdown. It's MDX, — essentially Markdown, but extensible!

MDX operates in the unified ecosystem of AST parsers, transformers and compilers. There's plenty of stuff that it can do! I installed some plugins that already existed in the ecosystem, and then wrote a few for my specific needs.

Existing plugins

Now onto the plugins I wrote myself. I'll start with simple ones.

rehype-override-jsx

I didn't like how [text](link) and <a href="link">text</a> result in different output. It's rationalized that raw HTML or JSX should result in exactly that, and should not be subject to component substitution (replacing links with custom <Link> component). Sure, that makes sense, but... I decided to write this plugin anyway. I'll probably get rid of it later.

rehype-code-meta

The code blocks' metadata is already processed by MDX, so it just needs to be parsed by another plugin. And that's what this simple plugin does, — parses and passes these metadata attributes to the <CodeBlock> component. You've already seen what the code blocks look like in Chapter 2: Code blocks.

remark-analyze

I wanted this plugin to determine what Markdown features a certain document uses, so that I could dynamically import only the features and components, that are actually used. But like I mentioned in Chapter 2: Code blocks, Next.js can't code-split dynamically imported modules yet. So this plugin is just there, not doing anything, for now.

remark-inline-css-color

This one adds little color boxes next to inline code elements containing CSS colors. I made a pretty nice, easy to extend regex for a bunch of different supported color syntaxes.

Like this: #ff0000, #00ff00, #0000ff.
Or this: rgb(175, 23, 187) hsl(360 88% 36%/0.5).

remark-emoji

Now, here, I had my eye on a few plugins, all doing different stuff with emojis:

I couldn't find a way for all three of these packages to work together, so I just copied the code from all of them and combined them into a single remark plugin. And that's pretty much it.

remark-toc-headings

This plugin looks at the AST and produces an array of all headings in it. The headings data is then forwarded to the outside code, and the server renders an interactive table of contents in a separate place (see to the side ⇒).

remark-custom-directives

As I mentioned before, remark-directive only parses the directives. They still need to be implemented by another plugin. So, I made a configurable directive processor, that replaces matching directives with JSX components.

Admonitions are still work-in-progress, so for now there's only the :icon[type] directive.

:icon[edit]
:icon[save]{alpha=0.5}
:icon[copy]{size=16px}

remark-embed

This one took a long time to implement. I started this one from scratch several times, trying to figure out the best way to design the API, organize the data-fetching, and split the code into functions and files. I ended up putting all the types in oembed/types.ts, the code for finding and transforming links in remark-embed.ts, and the rest of the oEmbed code in oembed/index.ts. Base API usage: await getOEmbed()?.response().

Additionally, instead of allowing all embeds registered in https://oembed.com/providers.json, I decided to whitelist the ones that I will actually use. And also, I'd like to implement each one separately, potentially adding lazy-loading or other features to them.

For example, look at this YouTube embed. It won't be an actual <iframe> until you click on it. This speeds up the page load, and may prevent some unwanted tracking.

The Ghost! - Streets of Rogue Custom Character
The Ghost! - Streets of Rogue Custom Character

Chapter 5: The backend

The registrar-nameserver-server chain is pretty confusing to me. After buying the domain on Namecheap, I guess I set its nameservers to Cloudflare's, through which I configured the DNS records to point to Vercel's servers, while Cloudflare remained as a proxy? I didn't retain much of what I did, but it works now!

Cloudflare CDN

After setting up my domain on Cloudflare, I've spent a couple days trying to figure out how its CDN and caching works. Even though there was plenty of documentation and examples, I still continued to doubt the efficiency of the default settings. It just felt too easy. In the end, the only things I configured were: some DNS records I needed for other stuff, and email routing. The default settings were perfect and accounted for every little thing I could need!

Supabase DB

While Supabase itself is pretty easy to use, I experienced a lot of self-inflicted issues with TypeScript types... I just couldn't figure out a good idiomatic way to fetch typed data. Long story short, I gave up on making "silly selector-based query builders". I decided to put all of the types in one file, and write every database query like a normal person:

/app/api/blog/index.ts
import "server-only";

/** Selects a single blog post with the specified id. */
export async function getBlogPostById(sb: Supabase, id: number): Promise<DbBlogPostWithAuthors | null> {
  const builder = sb.from("blog_posts").select(`
    id, created_at, edited_at, title, content, is_public, tags, slug,
    authors:blog_post_authors(*, user:users(*))
  `);

  return (await builder.eq("id", id).limit(1).maybeSingle()).data;
}

And yeah, that works just fine. I guess I just hoped to do something wild, like this:

/miscellaneous/silly-fantasies.ts
const selectUser = selector("users", "*");
const selectAuthor = selector("blog_post_authors", "*", { user: selectUser });
const selectPost = selector("blog_posts", "*", { authors: selectAuthor.multiple });

const data = await selectPost(supabase).eq("id", 123);

But now I realize that this kind of system would actually be much harder to maintain, than the one I currently have on the site. Sooo... it's a good thing that I eventually stopped trying to force this weird selector-based system, and moved on to actually do stuff.

Supabase Auth

It took some time for Supabase's Auth integration packages and setup instructions to stabilize. Upgrading from one version to another was bothersome, but now everything is pretty stable. You do the thing in the middleware, create /api/auth/callback route, and then log in on the client. The rest (configuring providers and RLS policies) was pretty intuitive.

Also, I had to build a wrapper for creating Supabase's clients, since I needed to configure the Next.js revalidation intervals for the database queries. And while at it, I also simplified the Supabase client creation process, exposing just a single createServerSupabase(,) method, that accepts "user" | "anonymous" | "SERVICE_ROLE" as a parameter and creates a client for the specified role. It made things significantly easier!

/lib/database/server.ts
export function createServerSupabase(role?: "user"): Promise<Supabase>;
export function createServerSupabase(role: "anonymous", next?: NextFetchRequestConfig): Supabase;
export function createServerSupabase(role: "SERVICE_ROLE", next?: NextFetchRequestConfig): Supabase;
export function createServerSupabase(role?: "user" | "anonymous" | "SERVICE_ROLE", next?: NextFetchRequestConfig) {
  switch (role || "user") {
    case "user":
      return getCookies().then(cookies => {
        return createServerClient(url, anonKey, configureFetch(cookies, next)) as Supabase;
      });
    case "anonymous":
      return createServerClient(url, anonKey, configureFetch(null, next)) as Supabase;
    case "SERVICE_ROLE":
      return createSupabaseClient(url, serviceKey!, configureFetch(null, next)) as Supabase;
    default:
      throw new Error("createServerSupabase was called with invalid client type.");
  }
}

Blogging

And now, with everything finally configured, set up and working... I can blog.

Not sure what I will blog about yet, but I probably will blog about something.