Repository for the website is on GitHub.
Recently, I’ve had the time to think a lot, read a lot, and watch and play a lot. I’ve wanted a solid place to log my thoughts instead of leaving it at the whims of sites like Trakt and Backloggd. Moreover, Trakt enforced a paltry limit of 100 items on watchlists which is encouraging me to never use the site which already had a very poor review mechanism where you’d leave a “comment” on things you’d watched. I had far more structured thoughts on movies and shows I’d watched and this motivated me to have a closer look at my website so I could post longer form thoughts there. I have also constantly experimented with new languages and would like to post some tutorials and thoughts somewhere.
I’d built the previous version of this website with Zola which is a fine static site generator built on Rust. The problem I had was that Zola was a fairly limited and cumbersome tool to develop a website. I use Tailwind a lot and using Tailwind meant that I had to keep a constantly running process that would parse all my templates and generate the required CSS into a file that I would have to reference in my templates. It also meant that because Zola uses Tera, a templating language based on the Jinja templating language, I would be editing templates a lot. I HATE editing templates because the templates are not type safe owing to there being no way to describe what data is going to be fed into the template. This means I am groping in the dark and figuring out something doesn’t work by running into errors which makes me feel gross.
I’ve been lately getting very much into Typescript and Astro is one of the more popular and relatively simple SSG frameworks out there, offering flexibility in terms of how you write components, be they React, Vue, Svelte etc. Astro offering type safety by defining type safe collections validated by Zod lets me have Intellisense when developing templates. Astro’s documentation is also excellent and covers 99% of all use cases that I need with a blog like this.
This is not going to be a tutorial as much as it is a simple documentation of my thoughts and desires as I made this blog. I had ideas for how I could make my site look fairly swanky and polished and decided to make it happen.
Inspiration
I’d recently visited the website of author Zak Archer (aka Artem Zakharchenko) and was deeply inspired by the simplicity and elegance of the design. I was also captivated by the cleanliness of the voidzero website as well as the patterns on the Tailwind website so decided to make an amalgamation of the concept where it would look like almost like a wireframe with clean thin lines everywhere.
In terms of content and text, I wasn’t sure what I wanted so I spent a significant amount of time looking at other developer profiles for something to click. I found the website of Ariel Salminen which is an extremely well rounded portfolio with a lot of sincere and clear writing and decided to use that as a starting point.
With a reasonably solid image of what I wanted in mind, I began the process of actually making it real.
Construction
As I’d mentioned before, I’d developed a liking for Astro and decided to move ahead with using it to build out the website. The setup process for Astro is extremely straightforward and well documented. Astro set up a basic scaffold for a blog at my instruction that included processing for MDX files and niceties such as a customisable RSS feed, syntax highlighting with Shiki, a sitemap, default OpenGraph headers and a lot of defaults such as an ESLint, Prettier and TSLint config that would make the development process were pleasant. With this done, I proceeded to make my own changes to the templates. Adding Tailwind, to start, was as easy as running a command and linking to a CSS file that would import Tailwind.
Home Page
Building the index page of the website was fairly straightforward. Since it’s just a simple HTML template styled with Tailwind, I moved fairly fast, using Devicon as a source for the icons of all the technologies I have experience with. I picked the font JetBrains Mono as the default font since it’s a really pretty monospaced font that I use for personal coding too and decided to use Victor Mono for headings since it’s go a fairly pleasing cursive variant.
The contact form on the home page is a simple form submitted using ajax and captured by Netlify using their form service (especially easy since the website is deployed on Netlify). I decided to add tooltips here and there to show descriptions and such implemented with TippyJS and found icons on Remix Icon that sort of fit the vibes I wanted.
About Me
This took some time to come together, mostly due to inertia and some level of anxiety when working on it, but ultimately I settled on having a good amount of text describing myself, a nice photo, some links, and my technial work history. The first three were were achieved in an uncomplicated manner but for my work history I decided to use JSON Resume which is a spec that provides a schema to detail out your technical experience using JSON which can also be used to generate PDFs. All I had to do was import the resume.json file and iterate through it in the template to generate the HTML.
---import resume from "../content/resume.json";---
{ resume["work"].map((work) => ( <div> <h3 class="text-xl">{work.name}</h3> <p class="text-sm">{work.position}</p> </div> ))}Astro is flexible and powerful enough that you can basically import any file that can be read by Javascript and it will let you use it in your template. This is the kind of shenanigans you miss with something like Zola. Very nice!
Blog
This would obviously be the most technically challenging part of the blog purely in terms of figuring out how the routing for Astro would work to support pagination, adding tags, and other things you’d expect out of a blog. A list of blog posts was fairly easy to implement by importing the collection of blog posts that was already provided by the default Astro blog template. This would fetch all the MD/MDX files inside the src/content/blog/ and pass the content through an RSS schema validated by Zod which would ensure the content would have the required RSS feed fields. I extended the schema with a couple of custom fields I wanted to use to add some spice like the date when the post would be updated and a hero image that would be used for OpenGraph tags enabling better embedding on social media websites.
// Blog pagesconst blog = defineCollection({ // Load Markdown and MDX files in the `src/content/blog/` directory. loader: glob({ base: "./src/content/blog", pattern: "**/*.{md,mdx}" }), // Type-check frontmatter using a schema schema: rssSchema .extend({ updatedDate: z.coerce.date().optional(), heroImage: z.string().optional(), }),});Now to display all the blog posts, all I’d have to do was fetch all the entries in the collection and list them out.
---import { getCollection } from "astro:content";
const posts = (await getCollection("blog")).sort( // This sorts posts by reverse publishing date so new articles are first (a, b) => (b.data.pubDate?.valueOf() || 0) - (a.data.pubDate?.valueOf() || 0),);---
<ul> { posts.map((post) => ( <li> <a href={`/blog/${post.id}/`}> <div> <img width={800} height={400} src={post.data.heroImage} /> </div> <div> <p>{ post.data.pubDate }</p> <h4>{post.data.title}</h4> </div> </a> </li> )) }</ul>For the individual blog posts themselves, I’d simply render the MDX out with Astro’s renderer and use Tailwind’s typography plugin to style the rendered text. The plugin styles almost all markdown tags, giving you nice headers, images, lists and so on.
---import { render } from "astro:content";import { getCollection } from "astro:content";
export async function getStaticPaths() { const posts = await getCollection("blog");
return posts.map((post, index) => ({params: { slug: post.id },}));}
const { post } = Astro.props;const { Content } = await render(post);---
<article class="prose prose-stone"> <Content /></article>The reason you need to get all the posts in a collection and return a map of all of them along with their slugs is because Astro, as a static site generator, wants advanced knowledge of all the possible routes when rendering the build of the full website and thus we need to fetch all the MDX files and return the slugs for all of them. The contents of each post will then be rendered into the Content HTML tag to build each page and the compiled HTML, CSS and JS will be served to visitors.
I decided to add tags to the the blog with links to the tags in each post with a tag page listing all the posts marked with that tag. This would require another page to be created for tags and, as before, Astro requires advance knowledge of all the tags to render out all the tag pages. I didn’t want to hardcode tags so I’d have to fetch all the posts and merge all the tags in all the posts together. RSS uses the category field to store a list of categories for each post and since this is already available in the RSS schema, we can fetch the lists and merge them and return a list of all the tags on the blog.
---import type { GetStaticPaths } from "astro";import { getCollection } from "astro:content";
export const getStaticPaths = (async ({ paginate }) => { const blog_posts = (await getCollection("blog")).sort( (a, b) => (b.data.pubDate?.valueOf() || 0) - (a.data.pubDate?.valueOf() || 0), );
const uniqueTags = [ ...new Set(blog_posts.map((post) => post.data.categories).flat()), ].filter((tag) => tag !== undefined);
return uniqueTags.flatMap((tag) => { const filteredPosts = [ ...blog_posts.filter((post) => post.data.categories?.includes(tag)), ]; return { params: { tag }, props: { posts: filteredPosts } }; });}) satisfies GetStaticPaths;
const { tag } = Astro.params;const { posts } = Astro.props;---The rest of the page would simply be the same as the list of posts from the index of the blog page.
With this, you get a fully functional blog page but I wanted a few more things to make it really fancy and polished.
- Dark mode
- A personal list of recommended articles from around the internet
- Pagination for blog posts and tags pages
- Table of contents for each post
- View transitions
- Better code highlighting
This would end up being what I’d spend the most time on with good reason. 90% of what I wanted was done. A simple index page, about page, a list of blog posts fetched from Markdown files, pages for the blog posts, and tag pages. The last 10% would be what would take me the rest of the way there and it would take the longest time which is what I expected. So I strapped in.
Beautification
Dark Mode
Dark mode was fairly easy to add. Tailwind comes with dark mode selectors and I could use some simple JS to create a button that would switch the class on the HTML tag to switch modes. I used this tutorial by Kevin Zuniga Cueller for the button and some light styling to add an icon that would switch between a sun and moon when appropriate.
Recommended articles list
I’d decided early on that I also wanted a sort of bookmarks list to spread the word on articles that I’d read and really liked and I decided to hook into Astro’s built-in collections feature to make this happen. I decided to store the collection of articles in a JSON file for now that I could edit, commit, and push to the repo to update and add to the list. This also opens me up to the option of later fetching from a simpler place like a CMS or an excel sheet or a Notion document at build time where I could add the link and some fields in a table and it would rebuild when we send a webhook to the hosting service.
I’d store a JSON in src/content/articles.json in the following format
{ "$schema": "./articles.schema.json", "articles": [ { "id": "1", "title": "Deus Ex: Human Revolution - Graphics Study", "author": "Adrian Courrèges", "categories": ["graphics", "technical", "gaming"], "description": "A great breakdown of how a single frame is rendered in Deus Ex: Human Revolution", "link": "https://www.adriancourreges.com/blog/2015/03/10/deus-ex-human-revolution-graphics-study/", "pubDate": "2025-05-31" }, //... ]}Now we set up an Astro collection to fetch the list of articles and render it in the blog list page.
const articles = defineCollection({ loader: file("./src/content/articles.json", { // This fetches the field "articles" in the JSON file parser: (text) => JSON.parse(text)["articles"], }),
schema: z.object({ id: z.string(), title: z.string(), author: z.string(), description: z.string().optional(), pubDate: z.coerce.date(), link: z.string().url(), categories: z.array(z.string()), type: z.enum(["article", "video"]).default("article"), }),});This kind of stuff is why I like Astro so much. With this simple change we get validation of the content and if I make any error in the JSON or any of the frontmatter in the markdown files, it’s going to shut the whole build down with an error. This also gives type safety in the templates when rendering the posts because we can simply fetch this collection and iterate on the returned data to display them in a list.
To use the collection, I just fetch all the posts from it and map through them.
---
//...const recommends = (await getCollection("articles")).sort( (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf(),);//...
---<ul>{ recommends.slice(0, 10).map((rec) => ( <li>{rec}</li> ));}</ul>It’s really that simple.
Pagination
For pagination, Astro provides a built-in system that very easily integrates with the existing getStaticPaths method. Pagination also requires that Astro be informed beforehand of all the possible pages you can have for a route so you need to fetch all the possible content and paginate it with a default page size.
To indicate that the blog page can accept a page path parameter, we need to change the filename of the blog index page to [page].astro.
---const posts = (await getCollection("blog")).sort( (a, b) => (b.data.pubDate?.valueOf() || 0) - (a.data.pubDate?.valueOf() || 0),);export const getStaticPaths = (async ({ paginate }) => { const posts = (await getCollection("blog")).sort( (a, b) => (b.data.pubDate?.valueOf() || 0) - (a.data.pubDate?.valueOf() || 0), );
return paginate(posts, { pageSize: 10 });}) satisfies GetStaticPaths;
const { page } = Astro.props;---
<ul> { posts.map((post) => ( page.data.map((post) => ( <li> <a href={`/blog/${post.id}/`}> <div> <img width={800} height={400} src={post.data.heroImage} /> </div> <div> <p>{ post.data.pubDate }</p> <h4>{post.data.title}</h4> </div> </a> </li> )) }</ul>// Links to pages<nav> <a href={page.url.first}>First</a> <a href={page.url.prev}>Previous</a> <span>Current URL</span> <a href={page.url.next}>Next</a> <a href={page.url.last}>Last</a></nav>As you can see, instead of simply passing on all the posts from the collection, we use the paginate parameter given to the function you pass to getStaticPaths and then pass the list of posts that we fetched to the pagination function with some options. This gives us a page object that contains the page of posts in page.data and all the URLs for the other pages in page.url. All the props are described here. We can do this same thing with the recommendations list page.
If I want to paginate tags, then it’s a little complex because we want to accept both the tag and the page as a parameter in the URL. Actually, it would have been complex if we didn’t simply have to send the tag parameter to the pagination function and move the [tag].astro page to [tag]/[page].astro.
---//...export const getStaticPaths = (async ({ paginate }) => { const blog_posts = (await getCollection("blog")).sort( (a, b) => (b.data.pubDate?.valueOf() || 0) - (a.data.pubDate?.valueOf() || 0), ); const uniqueTags = [ ...new Set(blog_posts.map((post) => post.data.categories).flat()), ].filter((tag) => tag !== undefined); return uniqueTags.flatMap((tag) => { const filteredPosts = [ ...blog_posts.filter((post) => post.data.categories?.includes(tag)), ]; return { params: { tag }, props: { posts: filteredPosts } }; return paginate(filteredPosts, { params: { tag }, pageSize: 10 }); });})
const { tag } = Astro.params;const { posts } = Astro.props;const { page } = Astro.props;---Table of contents
Once again, Astro is the MVP here. Astro’s content renderer for each post can return all the headings in the page. In the slug page, we can pass the headings and then list them out and it should work out of the box.
---export async function getStaticPaths() { const posts = await getCollection("blog");
const headings = await Promise.all( posts.map(async (post) => { const data = await render(post); return data.headings; }), );
return posts.map((post, index) => ({ params: { slug: post.id }, props: { post, headings: headings[index] }, }));}
const { post, headings } = Astro.props;const { Content } = await render(post);---
<table-of-contents> <nav id="toc" class="overflow-y-auto"> <h2 class="border-theme border-b py-2 text-lg font-semibold"> Table of Contents </h2> <ul class="list-inside py-2"> { headings.map((heading) => ( <li><a href={heading.slug}>{heading.text}</a></li> )) } </ul> </nav></table-of-contents>One drawback of Astro’s headings method is that it returns a flat list of headings in order with their depth starting from 1 for the title and then 2 for subheadings, 3 for the subsubheadings and so on. It is easier if we construct a tree of headings with children of top level headings as a list within the heading. It would have taken me some time for me to understand the rules of the headings and whether they are properly sorted based on where they appear so instead I followed this tutorial by Kevin Drum to construct the nested list.
To round it all off, I wanted the headings to highlight if they were on screen as you scrolled. I started looking into the IntersectionObserver API but it felt like I was already spending too much time so I decided to simply follow this tutorial by Fazza Razaq Amiarso to highlight headings with some minor tweaks so it would highlight all headers currently in view. There was an issue with sections not highlighting if there were no headers on the screen at any given time (which is easily possible when there’s a lot of text between headers) so to mitigate that, I followed this article by Billy Le which recommended using the remark plugin remark-sectionize. This plugin ensures that each section and subsection under a header is nested rather than the default of rendering as a flat list of tags. This mitigated the issue completely.
View Transitions
Adding basic View Transitions that blur pages into view as you move between them is as easy as simple adding a single component in your head tag. This is basically hooking into default browser behaviour with graceful fallbacks for unsupported browsers.
To transition elements or titles between pages, you use the transition:name property on each element on both pages. To transition the image between the blog list and the blog page I added transition:name={`blog-hero-image-${post-id}`} to the hero image tag on both pages.
<img width={800} height={400} src={post.data.heroImage} alt={post.data.title} transition:name={`blog-hero-image-${post.id}`}/><img width={1020} height={510} src={heroImage} alt={title} class="border-theme m-auto aspect-2/1 xl:border-x" transition:name={`blog-hero-image-${id}`}/>You can transition more elements including the headers, post dates etc but it looked too flashy and distracting so I opted to stick with the image itself. Adding the transition to the whole post item content box animates when you filter them by tags which is a much better use of the view transitions. Posts common between the tags and the main page will rendered on the page or animate in if they were “off screen” as it were.
One drawback of adding view transitions is that scripts that listen to events will not initialize again when you navigate between pages. This led to my tooltips and the table of contents breaking if you enter a blog post or a page that has tooltips from any other page. To mitigate this, the whole script that you’re running needs to be wrapped in an event listener that listens for the astro:page-load event or rendered as a web component1 so that they are re-initialised after a page has finished loading after a navigation.
function scriptToRun() { //..}
scriptToRun()document.addEventListener("astro:page-load", () => scriptToRun());Better code highlighting
The default code highlighting with Shiki that Astro provides is very powerful but it doesn’t support things like editor frames which could be used to indicate the filename, support for diff highlighting, line highlighting, and other niceties. For all that, we have a very nice plugin called Expressive Code with extremely simple setup and usage. After some tweaking of the default themes and some styling fixes using the configuration settings, I had a very pretty little code component.
const message = "Here's some JS";echo "And here's a command prompt"Publication
I decided to host the site on Netlify since it has a reasonable free tier with 100GB of bandwitch and… let’s be honest this site is not getting anywhere close to that. Connecting Netlify to the GitHub repo is incredibly simple and that enables deploys on every push. Good enough. To make the process fully smooth for Netlify’s serverless environment you need to run npx astro add netlify which adds a Netlify integration. A netlify.toml file with basic environment configuration, point my DNS to the Netlify page and I was Gucci.
Conclusion
While it took a while to fully flesh everything out, building this was a great undertaking that I really wanted to do because I wanted a reasonably pretty website that I could call my own. Overall, I am very much pleased with how it turned out and it would not have been possible without all the open source packages and tutorials written by the people I found. I highly recommend doing this exercise yourself and making your own custom place on the internet.
Comments