Astro
Integrate Wrytze blogs into an Astro site.
This guide walks you through building a complete blog section in an Astro site. You will create a fetch utility, a static blog listing page, and individual blog post pages with full SEO metadata. Astro's static-first architecture makes it an excellent choice for content-heavy sites that benefit from fast page loads.
Prerequisites
- An Astro 4+ project
- A Wrytze API key (see Quickstart)
- Your Wrytze website ID (found in Settings > Website in the dashboard)
Configure environment variables
Create a .env file at the root of your Astro project:
WRYTZE_API_KEY=wrz_your_api_key_here
WRYTZE_WEBSITE_ID=ws_your_website_id_hereThen add the environment variable schema to your Astro config so TypeScript can validate access:
import { defineConfig, envField } from 'astro/config'
import tailwindcss from '@astrojs/tailwind'
export default defineConfig({
integrations: [tailwindcss()],
env: {
schema: {
WRYTZE_API_KEY: envField.string({
context: 'server',
access: 'secret',
}),
WRYTZE_WEBSITE_ID: envField.string({
context: 'server',
access: 'public',
}),
},
},
})Setting context: 'server' and access: 'secret' on the API key ensures it is never bundled into client-side JavaScript. The website ID uses access: 'public' since it is not sensitive and may be needed in client-side scripts.
Create the fetch utility
Create a utility module that wraps fetch with your API key and base URL. This keeps all Wrytze API logic in one place.
import { WRYTZE_API_KEY, WRYTZE_WEBSITE_ID } from 'astro:env/server'
const BASE_URL = 'https://app.wrytze.com/api/v1'
interface Blog {
id: string
title: string
slug: string
excerpt: string | null
contentHtml: string
metaTitle: string
metaDescription: string
featuredImageUrl: string | null
featuredImageAlt: string | null
wordCount: number
readingTimeMinutes: number
publishedAt: string
websiteId: string
categories: Array<{ name: string; slug: string }>
tags: Array<{ name: string; slug: string }>
}
interface Pagination {
page: number
limit: number
total: number
pages: number
}
interface BlogListResponse {
data: Blog[]
pagination: Pagination
}
interface BlogDetailResponse {
data: Blog
}
async function wrytzeRequest<T>(path: string, params?: Record<string, string>): Promise<T> {
const url = new URL(`${BASE_URL}${path}`)
if (params) {
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value)
})
}
const response = await fetch(url.toString(), {
headers: {
'X-API-Key': WRYTZE_API_KEY,
},
})
if (!response.ok) {
throw new Error(`Wrytze API error: ${response.status} ${response.statusText}`)
}
return response.json()
}
export async function listBlogs(options: {
page?: number
limit?: number
category?: string
tag?: string
} = {}): Promise<BlogListResponse> {
return wrytzeRequest<BlogListResponse>('/blogs', {
website_id: WRYTZE_WEBSITE_ID,
page: String(options.page ?? 1),
limit: String(options.limit ?? 12),
...(options.category && { category: options.category }),
...(options.tag && { tag: options.tag }),
})
}
export async function getBlogBySlug(slug: string): Promise<BlogDetailResponse> {
return wrytzeRequest<BlogDetailResponse>(`/blogs/slug/${slug}`, {
website_id: WRYTZE_WEBSITE_ID,
})
}Build the blog listing page
Create the blog listing page that fetches all published blogs at build time and renders them as a card grid.
---
import Layout from '../../layouts/Layout.astro'
import { listBlogs } from '../../lib/wrytze'
const { data: blogs, pagination } = await listBlogs({ limit: 100 })
---
<Layout title="Blog" description="Read our latest articles and insights.">
<main class="mx-auto max-w-7xl px-4 py-12 sm:px-6 lg:px-8">
<h1 class="mb-8 text-4xl font-bold tracking-tight">Blog</h1>
<div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-3">
{blogs.map((blog) => (
<article class="group">
<a href={`/blog/${blog.slug}`} class="block">
{blog.featuredImageUrl && (
<div class="relative mb-4 aspect-[16/9] overflow-hidden rounded-lg">
<img
src={blog.featuredImageUrl}
alt={blog.featuredImageAlt ?? blog.title}
class="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
decoding="async"
/>
</div>
)}
{blog.categories.length > 0 && (
<div class="mb-2 flex gap-2">
{blog.categories.map((cat) => (
<span class="text-xs font-medium uppercase tracking-wide text-blue-600">
{cat.name}
</span>
))}
</div>
)}
<h2 class="mb-2 text-xl font-semibold group-hover:underline">
{blog.title}
</h2>
{blog.excerpt && (
<p class="mb-3 line-clamp-2 text-gray-600">{blog.excerpt}</p>
)}
<div class="flex items-center gap-3 text-sm text-gray-500">
<time datetime={blog.publishedAt}>
{new Date(blog.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>·</span>
<span>{blog.readingTimeMinutes} min read</span>
</div>
</a>
</article>
))}
</div>
</main>
</Layout>By default, Astro generates static HTML at build time. All blog pages are pre-rendered, which means zero JavaScript is shipped to the client and pages load instantly. Run astro build to regenerate pages when new content is published, or use on-demand rendering with an adapter for dynamic content.
Build the blog detail page
Create individual blog post pages using Astro's getStaticPaths function to generate a page for every published blog at build time.
---
import Layout from '../../layouts/Layout.astro'
import { listBlogs, getBlogBySlug } from '../../lib/wrytze'
export async function getStaticPaths() {
const { data: blogs } = await listBlogs({ limit: 100 })
return blogs.map((blog) => ({
params: { slug: blog.slug },
}))
}
const { slug } = Astro.params
const { data: blog } = await getBlogBySlug(slug!)
---
<Layout
title={blog.metaTitle}
description={blog.metaDescription}
image={blog.featuredImageUrl ?? undefined}
>
<main class="mx-auto max-w-3xl px-4 py-12 sm:px-6 lg:px-8">
<!-- Header -->
<header class="mb-8">
{blog.categories.length > 0 && (
<div class="mb-4 flex gap-2">
{blog.categories.map((cat) => (
<a
href={`/blog?category=${cat.slug}`}
class="rounded-full bg-blue-100 px-3 py-1 text-xs font-medium text-blue-700 hover:bg-blue-200"
>
{cat.name}
</a>
))}
</div>
)}
<h1 class="mb-4 text-4xl font-bold tracking-tight lg:text-5xl">
{blog.title}
</h1>
<div class="flex flex-wrap items-center gap-3 text-sm text-gray-500">
<time datetime={blog.publishedAt}>
{new Date(blog.publishedAt).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
</time>
<span>·</span>
<span>{blog.readingTimeMinutes} min read</span>
<span>·</span>
<span>{blog.wordCount.toLocaleString()} words</span>
</div>
</header>
<!-- Featured image -->
{blog.featuredImageUrl && (
<div class="relative mb-10 aspect-[16/9] overflow-hidden rounded-xl">
<img
src={blog.featuredImageUrl}
alt={blog.featuredImageAlt ?? blog.title}
class="h-full w-full object-cover"
/>
</div>
)}
<!-- Blog content -->
<article
class="prose prose-lg max-w-none prose-headings:scroll-mt-20 prose-a:text-blue-600 prose-img:rounded-lg"
set:html={blog.contentHtml}
/>
<!-- Tags -->
{blog.tags.length > 0 && (
<footer class="mt-12 border-t pt-6">
<div class="flex flex-wrap gap-2">
{blog.tags.map((tag) => (
<a
href={`/blog?tag=${tag.slug}`}
class="rounded-full border px-3 py-1 text-sm text-gray-600 hover:bg-gray-50"
>
#{tag.name}
</a>
))}
</div>
</footer>
)}
</main>
</Layout>The set:html directive renders raw HTML. Since the contentHtml comes from the Wrytze API (which sanitizes content during generation), this is safe. Never use set:html with untrusted user input.
Set up content styling
Install and configure @tailwindcss/typography to style the blog HTML content with the prose classes.
npm install @tailwindcss/typographyIn Tailwind CSS v4, plugins are added via @plugin in your CSS file:
@import "tailwindcss";
@plugin "@tailwindcss/typography";Create the base layout
If you do not already have a layout that handles <head> metadata, create one that accepts SEO props from each page.
---
interface Props {
title: string
description?: string
image?: string
}
const { title, description, image } = Astro.props
const canonicalUrl = new URL(Astro.url.pathname, Astro.site)
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
{description && <meta name="description" content={description} />}
<link rel="canonical" href={canonicalUrl} />
<!-- Open Graph -->
<meta property="og:title" content={title} />
{description && <meta property="og:description" content={description} />}
{image && <meta property="og:image" content={image} />}
<meta property="og:type" content="article" />
<meta property="og:url" content={canonicalUrl} />
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
{description && <meta name="twitter:description" content={description} />}
{image && <meta name="twitter:image" content={image} />}
</head>
<body>
<slot />
</body>
</html>Full project structure
After completing all the steps above, your blog-related files should look like this:
your-astro-site/
├── src/
│ ├── layouts/
│ │ └── Layout.astro # Base layout with SEO
│ ├── lib/
│ │ └── wrytze.ts # Fetch utility
│ ├── pages/
│ │ └── blog/
│ │ ├── index.astro # Blog listing
│ │ └── [slug].astro # Blog post
│ └── styles/
│ └── global.css # Tailwind + typography plugin
├── astro.config.mjs # Env schema
└── .env # API key & website ID