Blog Layout Conventions
Follow these conventions when creating or customizing blog layouts.
File Naming
Layout Files
src/layouts/blogs/styles/{style_name}/
├── IndexLayout.astro # Required - post listing
├── PostLayout.astro # Required - single post
├── styles.css # Optional - shared styles
└── index.ts # Optional - exports
Rules:
- Style folder uses
snake_case:blog_style1,minimal_blog - Must have both
IndexLayout.astroandPostLayout.astro - File names are exact (PascalCase with "Layout" suffix)
Component Files
src/layouts/blogs/components/{component}/{variant}/
├── {Component}.astro # PascalCase component name
└── styles.css # Associated styles
Props Interfaces
IndexLayout Required Interface
interface Props {
dataPath: string;
postsPerPage?: number;
}
PostLayout Required Interface
interface Props {
title: string;
description?: string;
date?: string;
author?: string;
tags?: string[];
content: string;
}
Do not:
- Add required props not passed by the route handler
- Rename existing props
Content Loading
Always Load in IndexLayout
IndexLayout should load posts internally:
const posts = await loadContent(dataPath, {
pattern: '*.{md,mdx}',
sort: 'date',
order: 'desc',
});
Error Handling
let posts: LoadedContent[] = [];
try {
posts = await loadContent(dataPath, {
pattern: '*.{md,mdx}',
sort: 'date',
order: 'desc',
});
} catch (error) {
console.error('Error loading blog posts:', error);
}
Empty State
{posts.length > 0 ? (
<div class="post-grid">
{posts.map(post => <PostCard {...} />)}
</div>
) : (
<div class="empty-state">
<p>No posts yet. Check back soon!</p>
</div>
)}
Date Handling
Display Format
Use consistent date formatting:
const formatDate = (date: string) => {
return new Date(date).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
};
Semantic HTML
Always use <time> with datetime attribute:
<time datetime={date}>
{formatDate(date)}
</time>
Sort Order
Blog posts should default to newest first:
const posts = await loadContent(dataPath, {
sort: 'date',
order: 'desc', // Newest first
});
Card Design
Required Elements
Post cards should include at minimum:
- Title (linked)
- Date
- Description or excerpt
Optional Elements
- Featured image
- Author
- Tags (limit to 3-4 visible)
- Read time
Accessibility
<a href={href} class="post-card">
{image && <img src={image} alt="" />} <!-- Decorative, empty alt -->
<h3>{title}</h3> <!-- Title provides context -->
</a>
Hover States
.post-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.post-card:hover {
transform: translateY(-4px);
box-shadow: var(--shadow-lg);
}
.post-card:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
}
Grid Layout
Responsive Grid
.post-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
@media (max-width: 640px) {
.post-grid {
grid-template-columns: 1fr;
gap: 1.5rem;
}
}
Consistent Card Heights
.post-card {
display: flex;
flex-direction: column;
height: 100%;
}
.post-card__content {
flex: 1;
display: flex;
flex-direction: column;
}
.post-card__description {
flex: 1;
}
Post Page
Content Width
Limit content width for readability:
.blog-post {
max-width: 720px;
margin: 0 auto;
padding: 0 1rem;
}
Typography
.blog-post__content {
font-size: 1.125rem;
line-height: 1.75;
}
.blog-post__content h2 {
margin-top: 2rem;
}
.blog-post__content p {
margin-bottom: 1.5rem;
}
Images
.blog-post__content img {
max-width: 100%;
height: auto;
border-radius: 8px;
margin: 2rem 0;
}
Tags
Display Limit
Show limited tags on cards:
{tags && tags.length > 0 && (
<div class="tags">
{tags.slice(0, 3).map(tag => (
<span class="tag">{tag}</span>
))}
{tags.length > 3 && (
<span class="tag-more">+{tags.length - 3}</span>
)}
</div>
)}
Tag Styling
.tag {
display: inline-block;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
background: var(--color-bg-tertiary);
border-radius: 4px;
color: var(--color-text-secondary);
}
Metadata Section
Post Header
<header class="blog-post__header">
<h1>{title}</h1>
<div class="blog-post__meta">
{author && (
<span class="meta-item">
<span class="meta-label">By</span>
<span class="meta-value">{author}</span>
</span>
)}
{date && (
<span class="meta-item">
<time datetime={date}>{formatDate(date)}</time>
</span>
)}
</div>
</header>
Separator
.blog-post__meta {
display: flex;
gap: 1rem;
color: var(--color-text-secondary);
}
.meta-item:not(:last-child)::after {
content: '·';
margin-left: 1rem;
}
Testing Checklist
Before shipping a blog layout:
- Index shows posts sorted by date (newest first)
- Cards display all metadata correctly
- Post pages render content properly
- Tags display on both index and post
- Dates format correctly
- Images load with proper sizing
- Responsive: cards stack on mobile
- Empty state shows when no posts
- Draft posts hidden in production
- Links work (card click, tag links)
Example: Complete Blog Layout
src/layouts/blogs/styles/blog_style2/
├── IndexLayout.astro
├── PostLayout.astro
└── styles.css
IndexLayout.astro:
---
import { loadContent, type LoadedContent } from '@loaders/data';
import PostCard from '../../components/cards/default/PostCard.astro';
import '../../components/cards/default/styles.css';
import './styles.css';
interface Props {
dataPath: string;
postsPerPage?: number;
}
const { dataPath, postsPerPage = 12 } = Astro.props;
let posts: LoadedContent[] = [];
try {
posts = await loadContent(dataPath, {
pattern: '*.{md,mdx}',
sort: 'date',
order: 'desc',
});
} catch (error) {
console.error('Error loading posts:', error);
}
---
<div class="blog-index">
<header>
<h1>Blog</h1>
</header>
{posts.length > 0 ? (
<div class="post-grid">
{posts.slice(0, postsPerPage).map(post => (
<PostCard
title={post.data.title}
description={post.data.description}
date={post.data.date as string}
href={`/blog/${post.slug}`}
image={post.data.image as string}
tags={post.data.tags as string[]}
/>
))}
</div>
) : (
<p class="empty">No posts yet.</p>
)}
</div>
PostLayout.astro:
---
import PostBody from '../../components/body/default/PostBody.astro';
import './styles.css';
interface Props {
title: string;
description?: string;
date?: string;
author?: string;
tags?: string[];
content: string;
}
---
<PostBody {...Astro.props} />