About twice a year I find myself building some sort of web application. This takes me out of my comfort zone - I very much prefer putting together the backend that powers a web application, and leave the front-end side of it to engineers who know what they're doing. And every time, without exception, I realise more and more that front-end is hard… but that doesn't mean I don't have fun doing it!
First things first, here are rough requirements I set for myself:
- simple design focused on typography
- a homepage with some key information about myself
- space to publish articles easily, preferably written in markdown
- easy to build and maintain
- fully serverless, ideally no ongoing cost if possible
Design
I am no designer, so I had to get creative in a different way. Probably a bit cliché in 2023, but I asked ChatGPT to suggest a color palette for my website. With that in hand, I plugged those in on coolors and made a couple of adjustments.
Next, typography. I was already set on having a typewriter effect header on the home page, so I had to go for a monospace font. I went on
fontjoy and generated a few pairings. I quite liked the well known Roboto Mono
, and with that locked, after a few
more tries I chose Athiti
as the secondary font.
Colours and font in hand, next stop was Figma. It didn't take long to put everything together, for both mobile and desktop. I was quite happy with the result!
And then it was time to make it real.
Tech stack
Given that in almost all cases I do this for personal projects, side projects or rough prototypes at work, I typically try to broaden my perspective by trying at least one new frontend thing, be it a framework, an app development platform or tooling. Lately, I had sort of boxed myself in the React / Next / Vercel ecosystem, which sounded appropriate for this project too. However, I had my eye on Svelte for quite some time and never got to try it… until now.
Even though it's been out for a while, Svelte is still not that well known. It has a completely different approach to React - all the hard stuff is done at compile time, and the purists out there are happy to learn that there's no Virtual DOM. In fact, Svelte is a language in itself, but even so I was surprised to see that the learning curve is orders of magnitude less steep than for React. My starting point was not vanilla Svelte however, but SvelteKit, which is to Svelte what Next is to React. Yes, it's great, but more on that in a bit.
Having settled on the base, I needed to find a way to minimise the amount of CSS I have to write, or ideally avoid writing any CSS at all - prime time
to pivot from the default Bootstrap to Tailwind. To get going, all I had to do was to import my fonts in app.html
and plug my color palette and my fonts into tailwind.config.js
:
<link
href="https://fonts.googleapis.com/css2?family=Athiti:wght@400;500;700&display=swap"
rel="stylesheet"
/>
<link
href="https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@500&display=swap"
rel="stylesheet"
/>
/** @type {import('tailwindcss').Config} */
export default {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {
colors: {
transparent: "transparent",
current: "currentColor",
black: "#0F0F0F",
white: "#F4F4F9",
"electric-violet": "#7B3EFF",
"light-blue": "#B8DBD9",
"atomic-tangerine": "#F7A072"
}
},
fontFamily: {
body: ["Athiti", "sans-serif"],
monospace: ["Roboto Mono", "monospace"]
}
}
};
Frequently going back and forth between my VSCode and the Tailwind docs, I started turning the Figma prototype into reality. I only had four pages:
src/routes/+page.svelte
- home pagesrc/routes/articles/+page.svelte
- page listing all the articlessrc/routes/articles/[slug]/+page.svelte
- each article's pagesrc/404/+page.svelte
- pretty self explanatory
For the first three I knew data will eventually be fetched from somewhere, so I set up +page.ts
counterparts for each
with a load
function returning hardcoded objects with the same data as the Figma prototype. By doing this, I made
it very easy for myself to move to a real data source when ready.
Navigation
The idea behind the navigation, if it's not obvious enough, is to replicate a directory structure. Given the small number of pages on the website, there are three different scenarios:
- homepage - the element displays
/home
it does not link to anything - articles page - the element displays
/home/articles
, wherehome
is a link andarticles
is not - single article page - the element displays
/home/articles/<article title>
, wherehome
andarticles
are links, and the article title is not
Global state management in SvelteKit is a breeze. I ended up going for a pattern where each page can
define the state of the navigation. To get there, I first created the component with the associated types in
src/lib/components/nav.svelte
:
<script context="module" lang="ts">
export type Link = {
name: string;
url: string;
};
export type NavPath = {
links: Array<Link>;
currentPath: string;
};
</script>
<script lang="ts">
export let navPath: NavPath;
</script>
<nav class="font-monospace text-base sm:text-lg">
/{#each navPath.links as linkedPath}
<span
><a
href={linkedPath.url}
class="text-atomic-tangerine hover:text-electric-violet transition-colors"
>{linkedPath.name}</a
></span
>/{/each}<span>{navPath.currentPath}</span>
</nav>
As you can see, the Nav
component takes a NavPath
object as its only prop, which is made of a number of links
and a currentPath
. Simple enough. Then I created a store under src/lib/store.ts
:
import { writable } from "svelte/store";
import type { NavPath } from "$lib/components/base/nav.svelte";
export const navPath = writable<NavPath>();
Here, navPath
will serve as the shared data store, holding the current navigation path used to render the component.
Since the navigation component will be shown on all pages, I added it to src/routes/+layout.svelte
as so:
<script lang="ts">
import Nav, { type NavPath } from "$lib/components/base/nav.svelte";
let currentNavPath: NavPath;
navPath.subscribe((value) => (currentNavPath = value));
</script>
<div
class="bg-black min-h-screen text-white flex min-h-screen h-full w-max-screen pl-2 pr-2 sm:pl-6 sm:pr-6 pt-3"
>
<div class="grid-cols-1 w-full md:w-4/6 lg:w-3/6">
{#if currentNavPath}
<Nav navPath={currentNavPath} />
{/if}
<slot />
</div>
</div>
By subscribing
to navPath
, we can use the value set by child pages. The downside here is that, theoretically,
currentNavPath
is reactive even though we don't need it to be, so it probably isn't the best solution but it
works well enough.
Deployment
Naturally, the next step after wrapping up the frontend using static data is to figure out where the actual data will come from. Without a decision around where the website will live however, it's hard to make that call given all the requirements I set initially, specifically around wanting to keep this as low cost as possible, even free, and going serverless.
My go-to solution for these situations is DigitalOcean, specifically the App Platform, which has a free tier for up to three static websites. In the end I decided to spice it up a bit.
Firebase sounds great on paper - if you can stomach the vendor lock-in, you get a lot of goodies, such as:
- a generous free tier
- multi-platform authentication
- real time database (Firestore)
- easy integration with a lot of external tools via extensions (think CRMs, email gateways, you name it)
- monitoring and analytics
… and a lot more.
So that's what I went for. The initial setup was a breeze. At first, I wrote my own CI on CircleCI (which is also my go-to CI platform, but I'll do an article on that later). I got rid of that very quickly though, as Firebase generates Github Actions automatically, so that took even more away from my hands.
Knowing where the website will be hosted, I was then free to continue the development.
Data
As a first time Firebase user wanting to learn as much as possible in as little time as possible while getting this project out of the door, I jumped in head first without thinking one bit at what the simplest way to tick all the boxes is. What I mean is that I started integrating Firestore.
I created three collections - one for my articles, one for my past jobs and one for my current projects. Fetching data from the database is as simple as it gets, there's nothing interesting to share around that really. The documentation, in typical Google fashion, is very good and the SDK is intuitive. I spent about 10 minutes moving all my data manually, coded the integration and deployed it. Everything was working perfectly.
When it comes to packaging the application, I had made the decision to just generate a static site using SvelteKit's
But then it hit me - what would the experience of writing a new post be? Well, I'd have to write it, then insert a new record
in the articles
collection and then trigger a new build manually in order to include the new article in the static website. Sure,
I could build myself a basic admin panel to spin up locally to write articles in, but that's extra work for not much value.
Mind you, at this point I already had all the Firestore queries written:
getArticles
-> to retrieve all articles ordered descending by publish dategetMostRecentArticleSummaries
-> to retrievecount
article summaries, ordered descending by publish date (anArticleSummary
only includes things likeslug
,title
,description
,publishedAt
andupdatedAt
, just enough to render the most recent articles component and the articles page)getArticleBySlug
-> to retrieve a single articlegetSimilarArticleSummaries
-> to return a numberArticleSummary
objects for articles similar to a given one. Each article has a number of tags, so the query looked for articles that have at least one of the tags present, and filled with other articles in case not enough were foundgetCurrentProjects
getPastJobs
After brainstorming a bit, I realised that having no database also gives me the best user experience. Why not store the articles
as markdown files inside the repo directly? This way I can write my articles locally in Markdown, preview the output, raise a PR,
see the article deployed in a dev app instance (if I'm too lazy to run npm run dev
and check it locally) and then merge. No need
for a backend, no need for manually tinkering with database records through the Firestore UI… perfect!
All my data lives in /data
:
data/currentProject.json
- a list ofCurrentProjects
objectsdata/pastJobs.json
- a list ofPastJobs
objectsarticles/<slug>
- directory for an article, where I have the following filesarticle.json
- this includes a partialArticleSummary
object, made of thetitle
,summary
andtags
article.md
- the article itself
If you followed this closely, you might have noticed that two important article fields are missing: publishedAt
and updatedAt
.
The first option that came to mind was to just store them in the article.json
files. But why bother manually formatting ISO timestamp
strings when there's an even nicer option, thanks to git
! We can use the commit log to find when a file was first committed and when
it was last updated, like so:
export const getGitCommitDateForFile = async (
path: string,
commit: "first" | "last"
): Promise<Date> => {
const result = shell.exec(
`git log --follow --format=%ad --date iso-strict ${path} | ${
commit == "first" ? "head" : "tail"
} -1`,
{
silent: true
}
);
if (result.code !== 0) {
throw new Error(`Failed to get ${commit} commit date: ${result.stderr}`);
}
if (result.stdout.trim() === "") {
throw new Error(`Failed to collect ${commit} commit date: no commits found for path ${path}`);
}
return new Date(result.stdout.trim());
};
Job done! All that was left was to replace the Firestore queries with variations of fs.readFile(path)
calls and
small amounts of logic to search and filter.
Wrap-up
There it is! Now you know the inner workings on this website and, more importantly, the iterations that got me here. All in all, it took me about 12 hours of work from idea, through prototype and final product, with some poor decisions in the middle that I shared above.
The key takeaway for me is how great SvelteKit fits to my way of thinking. It feels much more intuitive than React and there is far less you need to know to get productive. Honestly, I think it will be hard for me from now on to go back to React / Next unless there was a good reason to do so. Admittedly, there were a few instances where it was difficult to find answers, a natural consequence of the smaller size of the Svelte ecosystem, but I see a very, very bright future there.
Another important revelation was Tailwind. As someone not up to date with the latest in CSS, and with very limited recent practical experience, Tailwind made me feel like a styling superhero, and it was the main reason why I had so few headaches when recreating the Figma prototype.