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.
<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.
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
remark-gfm
adds some GitHub Flavored Markdown features, such as auto-links, tables, strikethrough text and checkboxed to-do lists.remark-breaks
turns soft breaks (single line-terminator\n
, that's ignored by Markdown) into hard breaks (translating into<br>
, which breaks text in the same paragraph). It's used by GitHub in some places: Issues, PRs, Releases, etc.remark-math
/rehype-katex
adds math expressions and renders them with .remark-directive
parses generic directives, that need to be processed by other plugins.remark-custom-heading-id
allows specifying custom ids for headings.
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:
remark-emoji
replaces:emoji_name:
with Unicode emojis.remark-a11y-emoji
addsaria-label
s to Unicode emojis.twemoji-parser
replaces Unicode emojis with Twemoji 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.
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:
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:
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!
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.