development

Tweaking This Site, Part 1: Content, Images, and the Homepage Face-lift

Published: September 11, 2026

⏱️ 8 min read | πŸ“ 1442 words

This is Part 1 of a series I'm starting to keep while I hammer this site into shape. Building a personal site is never really "done" β€” it's a pile of small decisions, dead ends, and happy accidents that are easy to forget a month later. So I'm writing them down as I go, in public, as a sort of devlog. Each part covers a batch of changes, and this one is the biggest: it starts from where I completely restructured how my posts are stored and ends with a brand-new logo.

About this series

Posts in this series are standalone β€” you don't need to read them in order. If one tweak interests you more than another, skip ahead. I'll link related posts where they exist.

The content restructure: every post gets a folder

The original setup was flat files: every post sat directly in src/content/blog/ as some-post.md, with its thumbnail living in public/images/blog/. That worked, but it meant frontmatter looked like this:

image: /images/blog/some-post.svg

The image path and the content were never visibly connected, and as the site grew the two directories started drifting out of sync. I wanted each post to be self-contained: one folder per post, with the content file and its images sitting side by side.

The new layout:

src/content/blog/
└── 10-09-2026-site-tweaks-part-1/
    β”œβ”€β”€ index.mdoc
    └── site-tweaks-part-1.svg         <-- referenced as ./site-tweaks-part-1.svg

The Astro glob loader picks up any *.mdoc/*.md nested this deep, and the frontmatter reference becomes a plain relative path:

image: ./site-tweaks-part-1.svg

The slug changes too β€” instead of my-post the URL is now derived from the folder name. I went with date-prefixed folders (10-09-2026-…) which conveniently sorts posts chronologically in the file explorer and makes the URL informative. Projects got the same treatment under src/content/projects/.

Astro 7's image() gotcha (and the 404 that taught me)

Here's where it got spicy. I switched the content schema from a plain string to Astro's built-in image() helper, which validates that the image exists and hands you back proper image metadata:

import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';

const blog = defineCollection({
  loader: glob({ pattern: '**/*.{md,mdx,mdoc}', base: './src/content/blog' }),
  schema: ({ image }) => z.object({
    // ...
    image: z.union([image(), z.string()]).optional(),
  }),
});

Then I rendered thumbnails with a plain <img src={image}>. In Astro 7, SVG references resolved by image() don't come back as a string β€” they come back as a component factory (so the framework can inline them). My template stringified that function into the src attribute… and the browser dutifully requested the function's source code as a URL:

[404] /(...args) => {    if (!validateArgs(args)) {      throw new AstroError({ ...

That log line took me a while to decode. The fix was a small Thumb component that normalizes whatever image hands us β€” an inline-SVG component, image metadata, a plain URL string, or nothing:

---
interface Props {
  image?: unknown;
  alt?: string;
  class: string;
  emptyClass?: string;
}
const { image, alt = '', class: className, emptyClass = '' } = Astro.props;

const isComponent = typeof image === 'function';
let src = '';
if (typeof image === 'string') {
  src = image;
} else if (image && typeof image === 'object' && 'src' in image) {
  src = String(image.src);
}
const Component = (isComponent ? image : null) as unknown as
  | ((props?: Record<string, unknown>) => unknown)
  | null;
---
{Component ? (
  <Component class={className} aria-hidden="true" />
) : src ? (
  <img src={src} alt={alt} class={className} loading="lazy" decoding="async" />
) : (
  <div class={`${className} ${emptyClass}`}></div>
)}

Then every card and detail page swaps its conditional <img> for a one-liner:

<Thumb image={post.data.image} class="mb-6 aspect-video w-full object-cover rounded-2xl" emptyClass="bg-slate-100" />

Three shapes, one component, zero surprises.

Wait β€” can I still use a URL?

Yes, and that's the reason for the z.union([image(), z.string()]) in the schema. image() alone only accepts local files. Wrapping it in a union means a thumbnail can be either:

# Option A: a local file (processed by Astro, gets optimized metadata)
image: ./my-thumb.svg

# Option B: an external URL (passed straight through as a string)
image: https://images.example.com/thumb.jpg

The Thumb component handles whichever one it gets. That's been handy for drafts where I grab a placeholder image from a stock site before making a proper one.

Markdoc: the content format that grows with you

I migrated the site's content to Markdoc (@astrojs/markdoc), which is Markdown plus custom tags. Instead of hand-rolling HTML for repeated patterns, I get components right inside the prose.

The UI Elements Playground project turned into a living document this way. Its markdoc.config.mjs registers four custom tags:

export default defineMarkdocConfig({
  tags: {
    callout: {
      render: component('./src/components/Callout.astro'),
      // attributes: type, title
    },
    comparisonTable: {
      render: component('./src/components/ComparisonTable.astro'),
    },
    progressbar: {
      render: component('./src/components/ProgressBar.astro'),
      selfClosing: true,
      // attributes: label, value, max
    },
    buttoneffect: {
      render: component('./src/components/ButtonEffect.astro'),
      selfClosing: true,
      // attributes: label
    },
  },
});

The selfClosing: true flag matters β€” it's what lets me sprinkle interactive components inline without wrapping content:

{% progressbar label="Player Health" value=72 max=100 /%}
{% buttoneffect label="Start Game" /%}

I wrote dedicated posts about the two flagship components if you want the deep dive: Building a Reusable Progress Bar and Creating a Button Shine Effect. Even the callout boxes you're reading right now are Markdoc tags.

The homepage face-lift

The Hero used to sit on a static background image. Now it runs my custom ParallaxColumns component β€” tall bars drifting at different speeds, powered by CSS scroll-driven animations with zero JavaScript. I got deep into the weeds on that one in its own post, but the short version is two modes:

  • mode="ambient" β€” a continuous, seeded animation loop for the entry screen
  • mode="scroll" β€” native animation-timeline: scroll(root) so bars lift as you scroll
<ParallaxColumns count={20} mode="ambient" title="Hi, I'm Nils πŸ‘‹" description="..." caption="" />

While I was in there I also replaced the generic placeholder header with a real identity (more on that below), and gave the page's lower half a proper footer for the first time.

The site previously just… ended. I built a proper footer that's now shared through the layout on every page:

  • Site β€” quick links to the main sections
  • Built with β€” a hat-tip to Astro, Tailwind, Markdoc, Heroicons
  • Thanks β€” leads to a dedicated /credits page

The credits page is data-driven, so thanking new tools later means editing one array:

---
const credits = [
  {
    category: 'Framework & tooling',
    items: [
      { name: 'Astro', href: 'https://astro.build', note: 'The web framework this site is built with.' },
      { name: 'Tailwind CSS', href: 'https://tailwindcss.com', note: 'Utility-first CSS framework used for styling.' },
      // ...
    ],
  },
];
---

It's a nice way to keep the colophon current without rewriting markup every time. And the little "Back to top ↑" link in the footer scrolls to an id="top" anchor on the body.

The logo: three bars that nod to the hero

The finishing touch was a logo that actually belongs to the site. Since the Hero is built from rounded vertical bars drifting in blues, I designed the mark to echo that: an "N" made of three bars β€” a solid left stem, a right stem that's shorter (like a parallax-offset column), and a gradient diagonal connecting them.

<svg viewBox="0 0 48 48" fill="none">
  <linearGradient id="diag" x1="15" y1="13" x2="36" y2="40">
    <stop stop-color="#60a5fa" />
    <stop offset="1" stop-color="#2563eb" />
  </linearGradient>
  <path d="M9 40V10" stroke="#3b82f6" stroke-width="7" stroke-linecap="round" />
  <path d="M15 13L34 40" stroke="url(#diag)" stroke-width="7" stroke-linecap="round" />
  <path d="M39 40V17" stroke="#93c5fd" stroke-width="7" stroke-linecap="round" />
</svg>

It became a reusable Logo.astro component with a color-adaptive wordmark (NILS / SANDERSON) that reads white on the dark hero and flips to dark once the header shows its blurred white background. It's wired into the header, the footer, and β€” scaled down to a rounded badge β€” the favicon.

What's on the slate for Part 2

This part covered the plumbing: content structure, image handling, the component library, and the site's visual identity. The next batch I'm planning:

  • Draft workflows β€” the blog index currently lists drafts; I want a proper draft/preview separation.
  • Per-component pages β€” fleshing out the UI Elements playground with proper docs and interactions.
  • Reading lists β€” tag and category browsing instead of one long feed.
  • Probably a few more things I haven't thought of yet β€” that's the joy of a devlog series.

If you made it this far, the credits page is where all the tools behind these tweaks get their hat-tips. Part 2 will pick up wherever I start tinkering next.