WrytzeWrytze Docs
Guides

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:

.env
WRYTZE_API_KEY=wrz_your_api_key_here
WRYTZE_WEBSITE_ID=ws_your_website_id_here

Then add the environment variable schema to your Astro config so TypeScript can validate access:

astro.config.mjs
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.

src/lib/wrytze.ts
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.

src/pages/blog/index.astro
---
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>&middot;</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.

src/pages/blog/[slug].astro
---
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>&middot;</span>
        <span>{blog.readingTimeMinutes} min read</span>
        <span>&middot;</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/typography

In Tailwind CSS v4, plugins are added via @plugin in your CSS file:

src/styles/global.css
@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.

src/layouts/Layout.astro
---
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

Next steps

On this page