development

Tweaking My Astro Site: Content, Images & Home

Published: September 11, 2026

⏱️ 13 min read | πŸ“ 2483 words

This is Part 1 of a devlog series. I am keeping it while I shape this Astro site. A personal website is never really "done." It grows through small decisions. It grows through dead ends too. And it grows through happy accidents. Most of those details are easy to forget. So I write them down in public. Each part covers a batch of changes. This one is the biggest. It starts with my content structure. It ends with a brand-new logo.

Think of this as a tour of the website. You can read the parts in any order. Each section stands on its own. I also link the deeper posts where they exist.

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.

Why I keep a devlog

This website is my playground. It is also my notebook. I try new patterns here before I use them anywhere else. Content for this blog gets written in Markdoc. Styles come from Tailwind CSS. Static pages are built with Astro. That stack is small. It is also easy to tweak.

A devlog changes how I work. It forces me to explain my choices. It shows mistakes in the open. It stops me from repeating them. Most importantly, it gives the site a history. Six months from now I can scroll back. I can see why a folder looks the way it does. That context is priceless.

Astro was the right base for this site. It ships zero JavaScript by default. I only add JS when a page truly needs it. Tailwind keeps my styling consistent. Markdoc keeps my writing structured. Together they let me move fast. I can publish a new post in minutes. That speed is why tweaks keep coming. It is also why this devlog can keep up.

Readability first

Short sentences are a goal on this blog. They keep the writing tight. They also make the syntax blocks easier to follow. I aim for under 20 words per sentence on average.

The content restructure: every post gets a folder

The original setup was flat files. Every post sat directly in src/content/blog/. Each one was a some-post.md. Its thumbnail lived in public/images/blog/. That worked at first. It did not age well. The frontmatter looked like this:

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

Two problems appeared. The image path and the content were never visibly connected. And the two folders drifted out of sync. I wanted each post to be self-contained. I wanted the content file and its images side by side.

The new layout makes that obvious:

src/content/blog/
└── 10-09-2026-site-tweaks-part-1/
    β”œβ”€β”€ index.mdoc
    └── pencil-on-black-and-white.jpg    <-- referenced as ./pencil-on-black-and-white.jpg

The Astro glob loader picks up nested files. Any *.mdoc or *.md below the base counts. The frontmatter reference becomes a plain relative path:

image: ./pencil-on-black-and-white.jpg

Small change. Big payoff. I can delete a folder and everything goes with it. I can rename a post and its images follow. Nothing points across directories anymore. Content collections stay tidy.

Slugs, dates, and sorting

The slug changes too. It is no longer the filename. It is the folder name. I chose date-prefixed folders like 10-09-2026-…. That choice does three jobs at once:

  • It sorts posts chronologically in the file explorer.
  • It makes the URL informative at a glance.
  • It tells me when a post was last touched.

Projects got the same treatment under src/content/projects/. The pattern is now uniform across the whole site.

Frontmatter as metadata

Every post opens with a frontmatter block. That block is the post's metadata. It holds the title and description. It holds the publish date too. It also carries the draft flag, the category, and the tags. The image path lives there as well. For this post I added two more keys. The slug key gives a stable name. The keywords key lists the main topics.

That metadata does real work. The title shows up in browser tabs. The description feeds social cards. The tags will drive future category pages. The keywords help readers scan a post fast. I treat frontmatter like a contract. If the keys change, the contract changes. So I keep it small and consistent.

Short keys are easier to reuse. pubDate reads the same in every post. category matches the blog filter. image points at a local file or a URL. None of it is magic. It is structure, and it pays for itself.

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

Here is where it got spicy. I switched the content schema from a plain string to Astro's built-in image() helper. That helper validates that the image exists. It also hands back proper image metadata. The schema looks like this:

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}>. That is the obvious first instinct. It is also the wrong one for SVGs. In Astro 7, an SVG reference resolved by image() does not come back as a string. It comes back as a component factory. That lets the framework inline the SVG. My template stringified that function into the src attribute. The browser then asked for that text as a URL. The dev server logged this gem:

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

That log line took me a while to decode. The root cause was simple. Astro was handing me a component. I was treating it like a path. The fix was a small Thumb component. It normalizes whatever image hands us:

---
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>
)}

The Thumb component handles four shapes. An inline-SVG component. Image metadata. A plain URL string. Or nothing at all. 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. I also added alt and loading="lazy" while I was in there. Accessibility and performance came along for free.

Wait β€” can I still use a URL?

Yes. That is exactly why the schema uses z.union([image(), z.string()]). The image() helper alone only accepts local files. Wrapping it in a union accepts either form:

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

# 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 flexibility is handy for drafts. I can drop a stock photo URL in early. I can swap in a designed SVG later. One line changes, nothing else does.

Markdoc: the content format that grows with you

The site's content runs on Markdoc (@astrojs/markdoc). Markdown is great for prose. Markdoc adds components on top. Instead of hand-rolling HTML, I get real tags inside the text. This review post of mine uses those tags too.

The UI Elements Playground project turned into a living document. 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 lets me sprinkle interactive components inline. No wrapper needed. The syntax stays clean:

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

The callout boxes on this page are Markdoc tags. The title, the hints, the warnings β€” all of it. Content stays readable. Components stay reusable. I wrote about the two flagship components in dedicated posts: Building a Reusable Progress Bar and Creating a Button Shine Effect.

The homepage face-lift

The Hero used to sit on a static background image. It looked fine. It felt flat. Now it runs my custom ParallaxColumns component. Tall bars drift upward. Each one moves at its own speed. The whole thing runs on CSS scroll-driven animations. There is zero JavaScript.

<ParallaxColumns count={20} mode="ambient" title="Hi, I'm Nils πŸ‘‹" description="..." caption="" />

Two modes are available. ambient runs a continuous loop. It suits an entry screen. scroll uses native animation-timeline: scroll(root). Bars lift as you scroll. I went deep on the details in its own post. The short version is that determinism matters. My first pass used Math.random() per column. The layout reshuffled on every reload. Index-derived values fixed that. Now the composition is stable between builds. It still looks organic.

prefers-reduced-motion matters too. Both modes respect it. Motion simply turns off for those users. The page stays calm.

While I was in there, I replaced the generic placeholder header. It now has a real identity. I also gave the page's lower half a proper footer for the first time.

The site previously just… ended. Content stopped at the last post. There was no way back. I built a proper footer that is 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. 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 keeps the colophon current without rewriting markup every time. A "Back to top ↑" link completes the footer. It scrolls to an id="top" anchor on the body.

This page is one of my favorites to maintain. It reminds me that a website is a team sport. Every dependency is a thank-you note. When I add a tool, I add a line. The footer links there on every page. That keeps the gratitude visible.

The logo: initials

The finishing touch was a logo that belongs to the site. My first attempt spelled out the full name. It worked in the footer. It felt loud in the header. So I pared it back to initials.

The font is Aladin. Simple but nice feathered tails. Here is the SVG that runs in the header and footer today:

<svg class="fill-inherit size-7" width="21" height="15" viewBox="0 0 21 15" xmlns="http://www.w3.org/2000/svg">
  <path d="M6.13928e-06 14.04C0.360006 11.88 0.540006 9.29 0.540006 6.27C0.540006 3.25 0.450006 1.29 0.270006 0.390001C0.270006 0.130001 0.570006 1.07288e-06 1.17001 1.07288e-06C2.09001 1.07288e-06 2.70001 0.350001 3.00001 1.05C3.02001 1.11 3.07001 1.14 3.15001 1.14C3.23001 1.14 3.29001 1.12 3.33001 1.08C3.95001 0.440001 4.91001 0.120001 6.21001 0.120001C7.51001 0.120001 8.54001 0.450001 9.30001 1.11C10.06 1.77 10.44 2.73 10.44 3.99C10.44 4.75 10.37 5.83 10.23 7.23C10.11 8.63 10.05 9.67 10.05 10.35C10.05 11.97 10.32 13.14 10.86 13.86C10.94 13.96 10.98 14.06 10.98 14.16C10.98 14.42 10.7 14.55 10.14 14.55C7.66001 14.55 6.42001 13.45 6.42001 11.25C6.42001 10.41 6.61001 9.24 6.99001 7.74C7.37001 6.24 7.56001 5.1 7.56001 4.32C7.56001 2.72 6.90001 1.92 5.58001 1.92C4.94001 1.92 4.45001 2.11 4.11001 2.49C3.79001 2.87 3.61001 3.31 3.57001 3.81C3.53001 4.29 3.51001 5.21 3.51001 6.57C3.51001 8.65 3.58001 10.46 3.72001 12L3.78001 12.66C3.78001 13.22 3.41001 13.65 2.67001 13.95C1.95001 14.27 1.32001 14.43 0.780006 14.43C0.260006 14.43 6.13928e-06 14.3 6.13928e-06 14.04ZM20.2024 9.72C20.2024 10.58 20.0324 11.33 19.6924 11.97C19.3724 12.61 19.0024 13.08 18.5824 13.38C18.1624 13.68 17.6624 13.92 17.0824 14.1C16.3224 14.34 15.6324 14.46 15.0124 14.46C13.7124 14.46 12.6324 14.21 11.7724 13.71C10.9124 13.19 10.4824 12.37 10.4824 11.25C10.4824 10.59 10.6624 10.05 11.0224 9.63C11.5824 9.01 12.2524 8.7 13.0324 8.7C13.2724 8.7 13.3924 8.81 13.3924 9.03C13.3924 9.05 13.3424 9.21 13.2424 9.51C13.1624 9.79 13.1224 10.15 13.1224 10.59C13.1224 11.03 13.2924 11.42 13.6324 11.76C13.9924 12.1 14.4224 12.27 14.9224 12.27C15.4224 12.27 15.8224 12.19 16.1224 12.03C16.4224 11.85 16.6324 11.63 16.7524 11.37C16.9324 11.01 17.0224 10.67 17.0224 10.35C17.0224 9.77 16.7324 9.27 16.1524 8.85C15.5724 8.43 14.9324 8.1 14.2324 7.86C13.5324 7.62 12.8924 7.22 12.3124 6.66C11.7324 6.1 11.4424 5.4 11.4424 4.56C11.4424 3.12 11.8924 2.04 12.7924 1.32C13.7124 0.600001 14.9524 0.240002 16.5124 0.240002C17.1924 0.240002 17.9024 0.340001 18.6424 0.540001C19.4024 0.720001 19.7824 1.04 19.7824 1.5C19.7824 1.94 19.7024 2.35 19.5424 2.73C19.4024 3.11 19.2224 3.3 19.0024 3.3C18.8824 3.3 18.8024 3.26 18.7624 3.18C18.7224 3.08 18.6524 2.97 18.5524 2.85C18.4524 2.73 18.2124 2.58 17.8324 2.4C17.4524 2.2 17.1224 2.1 16.8424 2.1C16.5824 2.1 16.4024 2.11 16.3024 2.13C16.2224 2.13 16.0724 2.16 15.8524 2.22C15.6324 2.26 15.4324 2.33 15.2524 2.43C14.7924 2.69 14.5624 3.07 14.5624 3.57C14.5624 3.89 14.6324 4.13 14.7724 4.29C14.9124 4.43 15.0224 4.54 15.1024 4.62C15.1824 4.7 15.3224 4.8 15.5224 4.92C15.7224 5.02 15.8624 5.09 15.9424 5.13C16.0224 5.17 16.2024 5.26 16.4824 5.4C16.7624 5.52 16.9424 5.6 17.0224 5.64C19.1424 6.64 20.2024 8 20.2024 9.72Z" fill="inherit"/>
</svg>

fill="inherit" is the trick that makes it adapt. The type picks up the color from wherever it sits. It reads white on the dark hero. It flips to dark once the header shows its blurred white background.

It lives in a reusable Logo.astro component. The component is wired into the header. It is wired into the footer. Scaled down for the favicon too.

What changed, in one breath

One post folder, one home. A schema that accepts files or URLs. A thumbnail component that tames Astro 7. Markdoc tags that extend Markdown. A parallax hero with no JavaScript. A footer with a home. A credits page that says thanks. A logo that finally matches the site. It is a lot of tweaks. Each one was small. Together they changed the whole feel of the site.

What's on the slate for Part 2

This part covered the plumbing. Content structure. Image handling. The component library. The site's visual identity. The next batch is already forming:

  • Draft workflows β€” the blog index currently lists drafts. I want a proper draft and preview separation.
  • Per-component pages β€” richer, dedicated docs pages for the UI Elements playground.
  • Reading lists β€” tag and category browsing instead of one long feed.
  • More short paragraphs β€” my readability pass is far from done.

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.