Building My Own Website: A Beginner-Friendly Overview
How I built kennytherex.dev with Astro and Markdown: the stack, the folder structure, the key code, and the order I did things in.
On this page
I wanted a corner of the internet that’s actually mine, not a template with my name on it. So I built this site from scratch, and I’ve already rebuilt it more than once.
This post is an overview, not a full tutorial. I’ll show the stack, the structure, and the important bits of code, and skip the rest. If you want to build something similar, this is enough to get you started.
The stack
Astro · TypeScript · CSS · Markdown/MDX · Motion One · GitHub Pages
- Astro ships almost zero JavaScript by default, so pages are fast. It’s built for content-heavy sites like blogs.
- Markdown for every post. I write in a
.mdfile, push, done. - Motion One for small animations (scroll reveals). All of them respect
prefers-reduced-motion. - GitHub Pages for free hosting, with a custom domain on top.
Basic structure
src/
├── content/ # all posts live here, one folder per section
│ ├── journal/
│ ├── cybersecurity/
│ ├── programming/
│ ├── notes/
│ └── config.ts # schema for the posts
├── components/ # reusable pieces
├── layouts/ # Base.astro (site shell), Post.astro (article)
├── pages/ # every file here becomes a route
└── styles/ # global.css
public/ # static files: favicon, images, robots.txt
astro.config.mjs
The one idea to take from this: content and design are separate. Posts are just Markdown files, and pages are generated from them. I never edit HTML to publish something.
Step 1: Create the project
npm create astro@latest my-site
cd my-site
npm install @astrojs/mdx @astrojs/sitemap @astrojs/rss motion
npm run dev
Then a small config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://your-domain.dev',
integrations: [mdx(), sitemap()],
markdown: {
shikiConfig: { theme: 'vesper', wrap: true }, // code highlighting
},
});
Step 2: Define your content
This is the best part of Astro. You describe what a post looks like, and Astro checks every post against it.
// src/content/config.ts
import { defineCollection, z } from 'astro:content';
const programming = defineCollection({
type: 'content',
schema: z.object({
title: z.string(),
description: z.string(),
date: z.string(),
tags: z.array(z.string()).optional().default([]),
draft: z.boolean().optional().default(false),
}),
});
export const collections = { programming };
If I forget a title or typo a field, the build fails and tells me which file. I’d much rather have that than a silently broken page.
A post then looks like this:
---
title: "My First Post"
description: "One line that shows up in the list and in search results."
date: "2026-10-21"
tags: ["Programming"]
draft: true
---
Write whatever you want here in Markdown.
draft: true keeps it hidden. I flip it to false when it’s ready.
Step 3: Turn posts into pages
One file generates a page for every post in a section:
---
// src/pages/programming/[slug].astro
import { getCollection } from 'astro:content';
import Post from '../../layouts/Post.astro';
export async function getStaticPaths() {
const posts = await getCollection('programming', ({ data }) => !data.draft);
return posts.map((post) => ({ params: { slug: post.slug }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<Post title={post.data.title} description={post.data.description}>
<Content />
</Post>
And the list page for the section:
---
// src/pages/programming.astro
import { getCollection } from 'astro:content';
const posts = (await getCollection('programming', ({ data }) => !data.draft))
.sort((a, b) => new Date(b.data.date).getTime() - new Date(a.data.date).getTime());
---
{posts.map((p) => (
<a href={`/programming/${p.slug}/`}>
<h2>{p.data.title}</h2>
<p>{p.data.description}</p>
</a>
))}
The !data.draft filter is what keeps unfinished posts off the site.
Step 4: One source of truth for sections
Instead of hardcoding each section’s heading and description in its own page, I keep them in one file:
// src/utils/categories.ts
export const sections = {
programming: {
label: 'Programming',
heading: 'Building things.',
description: "Code, tools, scripts, and whatever I'm building or learning.",
},
// journal, cybersecurity, notes...
};
Adding a new section means one new entry here, one new collection, and one page. That’s it.
The small extras
These made it feel like a real site, and each one is small:
Reading time
export function readingTime(content: string): string {
const words = content.trim().split(/\s+/).length;
return `${Math.ceil(words / 200)} min read`;
}
Theme saved between visits
const saved = localStorage.getItem('theme');
if (saved) document.documentElement.dataset.theme = saved;
function setTheme(name) {
document.documentElement.dataset.theme = name;
localStorage.setItem('theme', name);
}
Respect reduced motion (do this for every animation)
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduceMotion) {
// run animations
}
RSS feed with @astrojs/rss, a sitemap with @astrojs/sitemap, and a robots.txt in public/. The last bit of SEO I added was og:image tags and JSON-LD structured data in the base layout.
Step 5: Deploy
On GitHub Pages, I deploy with GitHub Actions. This is the standard Astro workflow:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
contents: read
pages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: withastro/action@v3
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
steps:
- uses: actions/deploy-pages@v4
For a custom domain, put a file named CNAME in public/ containing just your domain, and point your DNS at GitHub Pages.
The order I did things in
This matters more than the tools:
- Content first, design later. Get Astro running, define the schema, publish something. A boring site with real posts beats a pretty empty one.
- Structure second. Sections, tags, RSS, search. Once posts exist, you know what navigation you actually need.
- Polish third. Favicons, avatar, SEO, theme switcher. Stuff that makes it look finished.
- Redesign when it stops feeling like you. I only redesigned once the content was stable, so changing the look never meant rewriting posts.
Rules I follow now
- Run
npm run buildlocally before pushing. If it fails on your machine, it’ll fail in deployment. - Pin a version when an integration breaks. A plugin update broke my build once, and pinning fixed it.
- Write new posts as
draft: truefirst. - One change per commit, so when something breaks you know what did it.
What I learned
- The first version is never the final one. Mine is on v2 already.
- Failed builds taught me more than any tutorial did.
- Design is mostly deciding what to leave out. Going strictly black and white removed about 80% of my decisions.
If you’re thinking about building your own site, start small, publish early, and let it grow. That’s what this one did.