January 5th, 2026

Building a Personal Website - Part 1: Using Lume

Good to know before reading: basic HTML/CSS, running shell commands, editing files

You can check out the source code for this website here.

The situation: It was the middle of the Covid lockdown. I had some free time, and I knew enough HTML, CSS, and JavaScript to cobble a few pages together and to list "HTML/CSS/JavaScript" on my resume. I had some functional CSS code, and an AWS S3 bucket with a custom domain name.

Enter: an early version of my personal website:

Early website version

It's okay. It gets the job done, and it shows potential employers that I know how to Google things.

But I don't just want my website to be okay. I want it to be pretty neat. So let's fill up some space in the old GitHub contribution graph and learn some new tools.

Choosing the Tech

Lume for Static Site Generation

I don't need any server-side logic or persistent state management, so a static site will do the trick. See Lume's article about static sites for more justification. Using a more complex web app framework might provide some good practice, but it would be overkill for the current size of the site.

I could continue using raw HTML and CSS, but that's no fun. I might also want to add a blog at some point (right now, for instance). So let's try out a static site generator.

There are a lot of options, but I ended up using Lume for a few reasons:

GitHub for Project Management

I already have experience using GitLab for Agile-style project management, but I'd rather not host a server or pay for a project of this size.

GitHub is similar enough to GitLab, and most of my personal code is there already. Their free repos and issue tracking are more than enough to track the code changes and other work. I don't mind making the code public (that's kind of the point of a dev portfolio), and in this particular case I don't really care if GitHub uses my code to train Copilot. For more sensitive code bases, I might instead use a self-hosted GitLab server or a paid option.

For any coding project you plan to work on more than once, I recommend using some kind of Git workflow to track and apply changes. In theory you could just do everything on a single branch, but having a separate issue for each change and having a branch for each issue makes it much easier to track progress over time. Having a searchable and taggable list of issues also makes prioritization easier when planning what to work on next.

GitHub Pages for Hosting

Since I'm already using GitHub, I might as well use their hosting service called GitHub Pages. It's free, it doesn't have ads, and it supports using a custom domain.

The earlier version of the site used an AWS S3 bucket. That worked fine and was fairly inexpensive, but if I can host the site for free, that's even better. Especially if I (hopefully) end up getting more traffic later on.

Setting Up Lume

Installation

OK, let's get started by installing Lume and setting up the project skeleton.

First, we'll need to install Deno. On Windows, using non-admin PowerShell:

irm https://deno.land/install.ps1 | iex

Conveniently, this also adds deno to the path. Much simpler than installing node, in my opinion.

Then, from within the project folder (using any shell):

deno run -A https://lume.land/init.ts

This starts an interactive prompt with a few options for creating the deno project. I went with "Basic" setup, and chose not to install a CMS. Note that we're using Lume 3.1.2; the installation options may differ slightly in other versions.

This creates _config.ts for Lume and deno.json for Deno.

Next, I'll install the optional Lume CLI so that Lume commands are a bit shorter to type. This will let us run lume instead of deno task lume:

deno install --allow-run --allow-env --allow-read --name lume --force --reload --global https://deno.land/x/lume_cli/mod.ts

Finally, from VS Code, I'll install the Deno and Vento Template Support extensions for syntax highlighting. Vento is just a templating tool used by Lume that we'll explain later.

Lume Config

Now I'll set some default configurations for our Lume project. This is all handled in _config.ts, which is documented here.

There are three things we care about for now:

This gives us the following:

import lume from "lume/mod.ts";

const site = lume({
  src: "./src",
  server: {
    open: true,
    debugBar: false,
  },
});

export default site;

The other defaults are fine for now.

Creating the Project Structure

Adding a Page

Now that Lume is configured, we can start adding content and defining a basic folder structure.

First, we need to create a src folder where everything will live.

Within it, Lume will automatically discover .md files and generate HTML using them. To start, let's create a simple src/index.md file, which will be used to generate the main index.html (the landing page) for the site:

# Scott Fredericks

I'm a programmer guy.

I do stuff.

Now we can view our site by building it and starting a server using the lume -s command. We can also set the server to automatically watch for file changes using the -w flag, so that whenever we change our source or config files, the website updates immediately in the browser. This command will run in the background and list any file updates as we go:

lume -s -w

This generates a very basic HTML file at the site root and opens it in the browser:

Viewing in the browser for the first time

If you're not familiar with Markdown, it's just a simple markup language that's well-liked by developers. It's easy to edit from a text editor, and it's easy to reason about how the rendered version will look based on the text version. For example, this Markdown:

# Level One Header

Summary Text

## Level Two Header

- Item 1
  - Item 1.1
  - Item 1.2
    - Item 1.2.1
- Item 2
- Item 3

produces this HTML:

<h1>Level One Header</h1>
<p>Summary Text</p>
<h2>Level Two Header</h2>
<ul>
  <li>
    Item 1 <ul>
      <li>Item 1.1</li>
      <li>
        Item 1.2 <ul>
          <li>Item 1.2.1</li>
        </ul>
      </li>
    </ul>
  </li>
  <li>Item 2</li>
  <li>Item 3</li>
</ul>

and looks like this in the browser:

Markdown conversion

Great, our offline website works! But it's very ugly. We should add some CSS to make it less ugly. But before we can do that, we need to understand how Lume handles layouts, templates, and other resources. This is because in Lume, CSS files are handled like any other resource.

Applying a Layout

Lume uses a JavaScript-based templating engine called Vento. If you've never used a templating tool before, the basic idea is that you create template files that are similar to the final output. Within the template files, you can write code-based expressions instead of the actual content. Then, the templating engine "renders" the final output by evaluating the expressions and injecting their literal string values into the document.

For example, the following template code:

<p>Hello, {{ name }}. Your age is {{ currentYear - birthYear }}.</p>

might produce this HTML code, depending on the values of name, currentYear, and birthYear:

<p>Hello, John. Your age is 21.</p>

Here, anything within the double curly braces {{ and }} is treated as JavaScript code and converted to a string literal, which gets output as raw HTML. Note that the template doesn't "know" anything about the output format, so you can use expressions to generate HTML tags, comments, or any other kind of HTML code. You just need to be mindful about how you use escape characters between formats.

This makes it easy to re-use parts of a file that stay the same (like the boilerplate elements in an HTML file) while making the main content dynamic (like the text and images for your blog articles). This becomes more relevant the more pages you have in your site. If you need to update the document layout for 20 similar pages, then templating allows you to update a single template file instead of editing all 20 pages separately.

Let's use a template to define the basic HTML structure that most of our pages will use. Template files are expected to live in the _includes folder, so let's create a file called src/_includes/main.html.vto:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Scott Fredericks - {{ title }}</title>
  </head>
  <body>
    <h1>Main Layout - Added by Vento</h1>
    {{ content }}
  </body>
</html>

The final HTML file should have more info in the head section, but that can be added later.

Note the .html.vto extension. We could have called the file main.vto, but using .html.vto makes it easier to see that the output file type will be .html.

Within our template, we use the variables title and content. title is just a regular variable that we will define within the index.md file. content is a built-in variable that stores the rendered string value obtained by converting the source file (Markdown) to the template file type (HTML).

Next, to have our index.md file use this layout, we can specify the built-in layout variable at the top of index.md like so:

---
layout: main.html.vto
title: About Me
---

You can define whatever other variables you like and use them within template files the same way we use title and content.

Now the page includes the h1 element added by the layout, and uses the page title defined at the top of index.md:

HTML using a Vento layout

Default Data

Lume also has a way to define directory-level default variable values, so that we don't need to include the layout line for every file. This way, you don't risk forgetting to update the layout line everywhere if you rename the layout file. We can do this by creating a file named _data.yml in the directory that we want to apply the defaults in. Let's create src/_data.yml:

layout: main.html.vto

Now we can remove the layout line from the top of index.md, since a default value will be pulled from _data.yml. If you reload the page, you'll see that the layout is still applied.

These defaults extend to all child directories by default, but they can be overridden by putting another _data.yml file within the child directories you want to override. For example, if you wanted a blog directory where all of the articles use a different layout, you could create src/blog/_data.yml:

layout: blog_article.html.vto

All files in that directory would then use the layout defined in src/_includes/blog_article.html.vto, instead of using src/_includes/main.html.vto.

Adding Resources

Adding CSS

OK, now we know how to use (and re-use) HTML. Let's add some CSS.

Other than the option to use templating, CSS in Lume works the same way it does in standard web dev. Let's add a CSS file at src/css/main.css. The exact file path doesn't matter; I just like having a dedicated css folder because it keeps our CSS files organized. For now, we'll just specify the font:

body {
  font-family: monospace; /* Gotta look like a developer */
}

Now we can link the CSS file in the usual way within the HTML template file, by adding a link element inside of the head element and specifying which CSS file to use:

<head>
  ...
  <link rel="stylesheet" href="/css/main.css" />
  ...
</head>

By default, Lume only looks for certain file types (like .md and .yml) within the source directory, so we need to specify any other files (like CSS) that we want to add. To do this, we add a line in _config.ts:

site.add("/css");

This recursively adds all files in the src/css folder.

Now css/main.css should be added to the _site folder after building, and we should see the updated styling in the browser:

HTML with CSS

Note that when we apply styling, we want to target the HTML elements in the final output (in the _site folder), rather than the contents of the source Markdown files. Our CSS code only "knows about" the rendered HTML, not Markdown or any other Lume-specific features.

So, for example, to target all level 1 headers in CSS, you would use h1, not #:

h1 {
    ...
}

and not

# {
    ...
}

If you get confused, just remember that the final website consists of everything that gets output to the _site folder, and nothing else.

Adding JavaScript

JavaScript works more or less the same way as CSS; we can add all JavaScript files within a src/js folder, and add these files to the build using another line in _config.ts:

site.add("/js");

Then we can reference these js files using the <script> element like we normally would. For example:

<script src="/js/my_script.js"></script>

Adding Images

Images are slightly different since we are using Markdown instead of HTML. It's still pretty simple though.

Just like we did with CSS and JavaScript, we need to add the image files to the build, using another line in _config.ts:

site.add("/img");

Then, we can add images within md files using the following syntax:

![alt text](/img/filename.png)

Here, we expect an image to exist at src/img/filename.png. Note the / at the beginning of the file path, which is necessary to reference the output root (the _site folder). We can also specify custom alt text, which is used in the event that the image does not load correctly.

This will insert an <img> tag into the output HTML using a relative URL.

Adding Blog Articles Programmatically

Great, now we have a basic project structure, and we know how to work with all of the relevant file types. But in order to get the most out of a static site generator, we should try generating some dynamic content at compile time.

A good use case for this is a blog, where adding a blog article .md file should not only create a new URL in the site, but also update the list of blog articles on the main /blog page.

To do this, let's create a basic layout for our blog list page at src/blog/index.html.vto:

---
title: Blog
layout: main.html.vto
url: /blog/
---

<h1>Latest Articles</h1>

<!-- Articles will go here -->

This will be rendered to <domain name>/blog/index.html, where <domain name> is whatever our public site URL is. By adding url: /blog/, we can properly utilize pretty URLs so that the resulting URL is just <domain name>/blog/.

To designate files as blog articles, we'll add a blog_article tag to every article .md file that we want to appear in the list. We'll also add a date variable that we can list at the top of the article and use for sorting. Here's the top of an article .md file:

---
title: "Building a Personal Website - Part 1: Using Lume"
date: 2026-01-05 12:00
tags: [blog_article]
---

Note that JavaScript has some quirks when parsing dates. If we had just used "2026-01-05", then the date would be rendered as "January 4th, 2026". Adding 12:00 for noon gets around this and correctly renders "January 5th, 2026".

Also note that if the title includes certain characters like :, you'll need to wrap it in quotes.

In order to work with dates more effectively, let's add the Lume Date plugin by adding a couple of lines to _config.ts and restarting lume:

import date from "lume/plugins/date.ts";
...
site.use(date());

Lume provides a search.pages function that generates a list of pages based on tags and other properties. We can use this within our template to get all of our article pages and sort them based on date.

It would also be nice if our articles were grouped by year, without needing to define each year ahead of time. We want each year to have its own <h2> element, with articles published in that year appearing in a list underneath.

We can do this using a combination of templating and JavaScript logic. Here's the complete src/blog/index.html.vto:

---
title: Blog
layout: main.html.vto
url: /blog/
---

<h1>Latest Articles</h1>

{{# Get list of articles based on tag, in descending date order #}}
{{ set articles = search.pages("blog_article", "date=desc") }}
{{ set currentYear = null }}

{{ for article of articles }}
  {{# Check whether the year has changed #}}
  {{ set articleYear = article.date.getFullYear() }}
  {{ if articleYear != currentYear }}
    {{# Close previous years if this isn't the first year #}}
    {{ if currentYear != null }}
      </ul>
    {{ /if }}

    {{# Create a section for the new year #}}
    <h2>{{ articleYear }}</h2>
    <ul>

    {{ set currentYear = articleYear }}
  {{ /if }}
  
  {{# Render each article #}}
  <li>
    <article>
      <p>
        <time datetime="{{ article.date |> date('DATE') }}">
          {{ article.date |> date('HUMAN_DATE') }}
        </time> -
        <a href="{{ article.url }}">{{ article.title }}</a>
      </p>
    </article>
  </li>
{{ /for }}

{{# Close the last list tag if there were any articles #}}
{{ if articles.length > 0 }}
  </ul>
{{ /if }}

First, we get the list of articles and sort them in descending order based on date. We keep track of the year for each article in the currentYear variable and update it each time we find a new year.

{{ set articles = search.pages("blog_article", "date=desc") }}
{{ set currentYear = null }}

{{ for article of articles }}
  ...
{{ /for }}

For each article inside of the for loop, we want to check whether we've started a new year or not. If we have, we create new <h2> and <ul> elements, and if needed, close the previous <ul>. We also update currentYear:

  {{ set articleYear = article.date.getFullYear() }}

  {{ if articleYear != currentYear }}
    {{ if currentYear != null }}
      </ul>
    {{ /if }}

    <h2>{{ articleYear }}</h2>
    <ul>

    {{ set currentYear = articleYear }}
  {{ /if }}

Then, for each article, we generate a list element including the date and title:

<li>
  <article>
    <p>
      <time datetime="{{ article.date |> date('DATE') }}">
        {{ article.date |> date('HUMAN_DATE') }}
      </time> - 
      <a href="{{ article.url }}">{{ article.title }}</a>
    </p>
  </article>
</li>

Here, |> is a Vento pipeline operator that converts article.date into a string with the proper format. date is referring to the Lume Date plugin that we added in _config.ts, and DATE and HUMAN_DATE are specific format options.

Running this with a single blog article md file, we get this:

Rendered blog article list

Excellent! Lastly, we want every blog article to include the publication date at the top. To do this, we'll create src/_includes/blog_article.html.vto. It will be nearly identical to our main layout (which we can inherit), but before the content, we'll insert a single line with the date:

---
layout: main.html.vto
---

<em>{{ date |> date('HUMAN_DATE') }}</em><br />
{{ content }}

To apply this to all blog articles, we create src/blog/_data.yml:

layout: blog_article.html.vto

Looking at the article in the browser:

Article Date

Conclusion

Great! We've set up Lume, created layouts and styling templates, and built a dynamic blog. We finally have all of the same functionality that raw HTML/CSS/JavaScript has, but with a templating framework and live updates. Now we are free to focus on the actual content of the site.

In the next article, we'll look at designing a theme with a dynamic background using JavaScript and CSS.