Lesson 4 · Core course
Write in Markdown and publish deliberately
Markdown articles have their own URLs, appear in an archive, and remain private until you explicitly publish them.
Sign in to save progress across devices
On this page
1. Define the metadata
An article begins with YAML frontmatter between --- lines. It describes the article; the rest of the file is your writing. We use a content collection so Astro can validate these fields before building. The loader/schema API is documented in Astro content collections.
Create these files:
File: src/lib/content-date.ts
import { z } from 'astro/zod';
export const contentDate = z
.union([z.date(), z.iso.date(), z.iso.datetime({ offset: true })])
.pipe(z.coerce.date());
File: src/content.config.ts
import { defineCollection } from 'astro:content';
import { glob } from 'astro/loaders';
import { z } from 'astro/zod';
import { contentDate } from './lib/content-date';
const blog = defineCollection({
loader: glob({ base: './src/content/blog', pattern: '**/*.md' }),
schema: z.object({
title: z.string().trim().min(1),
description: z.string().trim().min(1),
pubDate: contentDate,
updatedDate: contentDate.optional(),
draft: z.boolean().default(true),
tags: z.array(z.string().trim().min(1)).default([]),
}),
});
export const collections = { blog };
Date validation happens before coercion. A blank YAML date becomes null; blindly turning it into a JavaScript Date can produce an unintended date. Use valid calendar dates and explicit timezones for timestamps.
2. Put publication policy in one place
Create a small rule that every public surface will use:
File: src/lib/publication.ts
interface PublicationData {
draft: boolean;
pubDate: Date;
}
export function isPublished(data: PublicationData, now = new Date()): boolean {
return data.draft === false && data.pubDate.getTime() <= now.getTime();
}
File: src/lib/posts.ts
import { getCollection, type CollectionEntry } from 'astro:content';
import { isPublished } from './publication';
export async function getPosts(includeUnpublished = false): Promise<CollectionEntry<'blog'>[]> {
const now = new Date();
const preview = import.meta.env.DEV && includeUnpublished;
const posts = await getCollection('blog', ({ data }) => preview || isPublished(data, now));
return posts.sort((a, b) =>
b.data.pubDate.getTime() - a.data.pubDate.getTime() || a.id.localeCompare(b.id)
);
}
export function postHref(id: string): string {
return `/blog/${id.split('/').map(encodeURIComponent).join('/')}/`;
}
export function formatDate(date: Date): string {
return new Intl.DateTimeFormat('en', {
year: 'numeric', month: 'long', day: 'numeric', timeZone: 'UTC',
}).format(date);
}
The default is unpublished. A production build includes a post only when draft: false and its publication time has arrived. Development can render a draft at its direct URL, but production cannot generate that route.
3. Write three practice files
Create a public post:
File: src/content/blog/hello-world.md
---
title: "Why I am starting this blog"
description: "A small beginning, and a place to keep learning."
pubDate: "2026-10-01"
draft: false
tags: [notes, beginnings]
---
I wanted a place for thoughts that deserve more than a passing message.
## What I hope to write
- Questions I have not resolved yet.
- Things I learn by building.
- Ideas I change my mind about.
I do not need a perfect answer before I begin.
Use today’s date or an earlier date for your real first post. The example date is already in the past for this course edition. Do not repeat the title as # ... in the body; the article layout supplies the page’s main heading.
Create a draft for checking privacy:
File: src/content/blog/private-draft.md
---
title: "A draft I am still writing"
description: "This example must stay out of the public build."
pubDate: "2026-10-01"
draft: true
---
COURSE_PRIVATE_DRAFT_MARKER
This practice draft is visible only through the local development preview.
Create a future post:
File: src/content/blog/future-note.md
---
title: "A future note"
description: "A publication-date test."
pubDate: "2999-01-01"
draft: false
---
COURSE_FUTURE_POST_MARKER
These markers are harmless test text, not real secrets. They will help us check generated output in Lesson 06.
4. Generate article routes
Create src/pages/blog/[...slug].astro, including the brackets in the filename:
File: src/pages/blog/[...slug].astro
---
import { render, type CollectionEntry } from 'astro:content';
import Page from '../../layouts/Page.astro';
import { getPosts, formatDate } from '../../lib/posts';
import { isPublished } from '../../lib/publication';
export async function getStaticPaths() {
const posts = await getPosts(import.meta.env.DEV);
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
interface Props { post: CollectionEntry<'blog'>; }
const { post } = Astro.props;
const { Content } = await render(post);
const unpublished = !isPublished(post.data);
---
<Page title={post.data.title} description={post.data.description} noindex={unpublished}>
<article class="prose">
{unpublished && <p class="draft-notice">Local preview: this post is unpublished.</p>}
<h1>{post.data.title}</h1>
<p class="muted"><time datetime={post.data.pubDate.toISOString()}>{formatDate(post.data.pubDate)}</time></p>
{post.data.updatedDate && <p class="muted">Updated {formatDate(post.data.updatedDate)}</p>}
<Content />
</article>
</Page>
For hello-world.md, the URL is /blog/hello-world/. Keep filenames descriptive and stable; changing one changes the article’s address. Nested folders become nested paths.
5. Build the archive and homepage
File: src/components/PostList.astro
---
import type { CollectionEntry } from 'astro:content';
import { postHref, formatDate } from '../lib/posts';
interface Props { posts: CollectionEntry<'blog'>[]; }
const { posts } = Astro.props;
---
{posts.length === 0 ? <p>No posts published yet.</p> : (
<ul class="post-list">
{posts.map((post) => (
<li>
<h2><a href={postHref(post.id)}>{post.data.title}</a></h2>
<time class="muted" datetime={post.data.pubDate.toISOString()}>{formatDate(post.data.pubDate)}</time>
<p>{post.data.description}</p>
</li>
))}
</ul>
)}
File: src/pages/blog/index.astro
---
import Page from '../../layouts/Page.astro';
import PostList from '../../components/PostList.astro';
import { getPosts } from '../../lib/posts';
const posts = await getPosts();
---
<Page title="Writing">
<h1>Writing</h1>
<PostList posts={posts} />
</Page>
Replace src/pages/index.astro:
File: src/pages/index.astro
---
import Page from '../layouts/Page.astro';
import PostList from '../components/PostList.astro';
import { getPosts } from '../lib/posts';
const posts = (await getPosts()).slice(0, 5);
---
<Page>
<h1>A place to think out loud.</h1>
<p>I write about the questions I keep returning to.</p>
<PostList posts={posts} />
<p><a href="/blog/">All writing</a> · <a href="/about/">About me</a></p>
</Page>
Checkpoint
In pnpm dev, open /blog/hello-world/ and /blog/private-draft/. The latter should show a local-preview notice. The archive should list only the public post.
Stop development, run pnpm build, then pnpm preview. The public article exists; /blog/private-draft/ and /blog/future-note/ should return 404. Neither practice post should appear on the homepage or archive.
A future date does not schedule a deployment. It is evaluated during a build. The article appears only after a build runs at or after its publication time. Keep private attachments out of public/, which is always copied into the deployment.