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.
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.svgThe 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.svgThe 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.svgThe 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.jpgThe 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 screenmode="scroll"β nativeanimation-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.
Footer + a credits page
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
/creditspage
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.