Production cheatsheet from ActiveWizards Astro + Cloudflare Pages deployment (147 pages).
Content Collections (src/content.config.ts)
Astro 5 uses glob loaders (replaces the old src/content/ magic folder convention).
import { defineCollection, z } from 'astro:content';
import { glob } from 'astro/loaders';
const blog = defineCollection({
loader: glob({ pattern: '**/*.md', base: './src/content/blog' }),
schema: z.object({
title: z.string(),
description: z.string(),
publishedAt: z.coerce.date(),
updatedAt: z.coerce.date().optional(),
tags: z.array(z.string()).default([]),
metaTitle: z.string().optional(),
metaDescription: z.string().optional(),
ogImage: z.string().optional(),
readingTime: z.number().optional(),
}),
});
export const collections = { blog };
Key points:
z.coerce.date()handles ISO date strings from frontmatter.default([])prevents undefined on optional arrays- Run
astro syncafter changing schemas to regenerate types
Dynamic Routes ([...slug].astro)
Rest parameter [...slug] catches nested paths. Requires getStaticPaths() for SSG.
---
import { getCollection, render } from 'astro:content';
import BlogLayout from '../../layouts/BlogLayout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.id },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await render(post);
---
<BlogLayout title={post.data.title}>
<Content />
</BlogLayout>
post.idis the filename without extension (e.g.,my-postfrommy-post.md)post.datacontains validated frontmatter fieldsrender()returns{ Content }component for markdown body
Pagination
Manual pagination (AW approach)
Page 1 handled by blog/index.astro, pages 2+ by blog/[page].astro:
---
export async function getStaticPaths() {
const POSTS_PER_PAGE = 12;
const allPosts = await getCollection('blog');
const totalPages = Math.ceil(allPosts.length / POSTS_PER_PAGE);
return Array.from({ length: totalPages - 1 }, (_, i) => ({
params: { page: String(i + 2) },
props: { currentPage: i + 2 },
}));
}
const { currentPage } = Astro.props;
const allPosts = await getCollection('blog');
const sorted = allPosts.sort((a, b) =>
new Date(b.data.publishedAt).getTime() - new Date(a.data.publishedAt).getTime()
);
const startIndex = (currentPage - 1) * POSTS_PER_PAGE;
const paginatedPosts = sorted.slice(startIndex, startIndex + POSTS_PER_PAGE);
---
Built-in paginate() (alternative)
---
export async function getStaticPaths({ paginate }) {
const posts = await getCollection('blog');
return paginate(posts, { pageSize: 12 });
}
const { page } = Astro.props;
// page.data = current page items
// page.currentPage, page.lastPage, page.url.prev, page.url.next
---
Layout Chaining
<!-- BaseLayout.astro -->
<html>
<head>...</head>
<body>
<Nav />
<main><slot /></main>
<Footer />
</body>
</html>
<!-- BlogLayout.astro -->
---
import BaseLayout from './BaseLayout.astro';
interface Props { title: string; description: string; }
const { title, description } = Astro.props;
---
<BaseLayout title={title} description={description}>
<article class="prose">
<h1>{title}</h1>
<slot />
</article>
</BaseLayout>
Component Props
---
interface Props {
title: string;
variant?: 'primary' | 'secondary';
items: string[];
}
const { title, variant = 'primary', items } = Astro.props;
---
Slots (Default + Named)
<!-- Card.astro -->
<div class="card">
<div class="card-header"><slot name="header" /></div>
<div class="card-body"><slot /></div>
<div class="card-footer"><slot name="footer" /></div>
</div>
<!-- Usage -->
<Card>
<h2 slot="header">Title</h2>
<p>Body content goes in default slot</p>
<button slot="footer">Action</button>
</Card>
Styles
Scoped (default): tags are component-scoped automatically.
<style>
/* Only applies to elements in THIS component */
h1 { color: var(--color-accent); }
/* Escape scoping for children/markdown */
:global(.prose h2) { margin-top: 2rem; }
</style>
Global CSS: Import in your base layout.
---
// BaseLayout.astro
import '../styles/global.css';
---
Tailwind v4 (global.css):
@import "tailwindcss";
@theme {
--color-bg-primary: #0A0A0F;
--color-accent: #00D4AA;
--font-sans: 'Geist Sans', system-ui, sans-serif;
--font-mono: 'Geist Mono', monospace;
}
Images
---
import { Image } from 'astro:assets';
import heroImage from '../assets/hero.png';
---
<Image src={heroImage} alt="Description" width={800} height={400} />
widthandheightrequired for local images- Remote images need
width/heightor explicitinferSize - Images in
public/are not optimized (use as-is)
Environment Variables
| Scope | Prefix | Access |
| Server only | none | import.meta.env.SECRET_KEY |
| Client + Server | PUBLIC_ | import.meta.env.PUBLIC_GA_ID |
Defined in .env:
PUBLIC_GA_ID=G-XXXXXXX
OPENROUTER_KEY=sk-...
Markdown / Rehype / Remark Plugins
Configured in astro.config.mjs:
import rehypeSlug from 'rehype-slug';
import rehypeAutolinkHeadings from 'rehype-autolink-headings';
import rehypeExternalLinks from 'rehype-external-links';
import { remarkReadingTime } from './src/plugins/remark-reading-time.mjs';
export default defineConfig({
markdown: {
rehypePlugins: [
rehypeSlug,
[rehypeAutolinkHeadings, { behavior: 'wrap', properties: { class: 'heading-anchor' } }],
[rehypeExternalLinks, { target: '_blank', rel: ['noopener', 'noreferrer'] }],
],
remarkPlugins: [remarkReadingTime],
},
});
Integrations (AW Production Stack)
// astro.config.mjs
import sitemap from '@astrojs/sitemap';
import robotsTxt from 'astro-robots-txt';
import compress from 'astro-compress';
import expressiveCode from 'astro-expressive-code';
import pagefind from 'astro-pagefind';
import icon from 'astro-icon';
export default defineConfig({
site: 'https://activewizards.com',
vite: { plugins: [tailwindcss()] },
integrations: [
expressiveCode({ themes: ['github-dark-default'] }),
sitemap(),
robotsTxt(),
icon(),
pagefind(),
compress(), // MUST be last — compresses after all other transforms
],
});
Build & Dev
# Development
npx astro dev # Local dev server (default :4321)
npx astro dev --host # Expose to LAN
# Build
npx astro build # Output to dist/
npx astro preview # Preview built site locally
# Utilities
npx astro sync # Regenerate types after schema changes
npx astro check # TypeScript type checking
Common Gotchas
: rehype-autolink-headings with behavior: 'wrap' wraps the heading text in an anchor. CSS selectors targeting h2 directly still work, but h2 > a exists.content.config.ts, run astro sync or the dev server won't pick up new fields.@theme block instead of tailwind.config.js. The @import "tailwindcss" replaces the three @tailwind directives.compress() must be last: The astro-compress integration must be the final integration in the array, or it can't compress output from other integrations.class:list for conditional classes: Use Astro's built-in class:list directive:
<a class:list={['page-btn', { active: page === currentPage }]}>
is not scoped. Use :global() or put styles in global.css.params: { page: String(i + 2) } — numeric params cause build errors.trailingSlash: 'always' in config.getCollection() in multiple places: Each call re-reads all files. For blog post pages that also need all posts (for related posts), pass allPosts via props from getStaticPaths.expressiveCode() must come before sitemap() in integrations array to ensure code blocks are processed before sitemap generation.