Optimizing the Static Build (Part 4/7)
Parts 1-3 covered the stack: Astro, Contentful, and how they connect. The pages generate, the content renders. But there’s a difference between “it works” and “it’s production-ready.”
Let’s close that gap with images, SEO, sitemaps, RSS, and a preview workflow.
What the build produces
Running astro build gives you a dist/ folder of plain files:
dist/
├── index.html
├── en/
│ ├── blog/
│ │ ├── index.html
│ │ └── hello-world/index.html
│ └── about/index.html
├── de/
│ └── ...
├── _astro/
│ ├── index.DK2a8b1c.css
│ └── hero.B3kf9d2e.webp
├── robots.txt
└── sitemap-index.xml
Every page gets its own directory with an index.html. Clean URLs like /en/blog/hello-world just work. No .html extensions.
Images done right
For local images, Astro handles optimization automatically:
---
import { Image } from 'astro:assets'
import heroImage from '../assets/hero.jpg'
---
<Image
src={heroImage}
alt="Hero section"
widths={[400, 800, 1200]}
sizes="(max-width: 800px) 100vw, 1200px"
format="webp"
/>
It generates multiple sizes in WebP and outputs a <picture> with srcset. That 2MB JPEG? Handled.
For Contentful images, it’s even easier — their CDN does the heavy lifting via URL params:
---
function contentfulImage(url: string, width: number, format = 'webp') {
return `https:${url}?w=${width}&fm=${format}&q=80`
}
const imageUrl = post.fields.featuredImage?.fields.file.url
---
{imageUrl && (
<picture>
<source
srcset={`
${contentfulImage(imageUrl, 400)} 400w,
${contentfulImage(imageUrl, 800)} 800w,
${contentfulImage(imageUrl, 1200)} 1200w
`}
sizes="(max-width: 800px) 100vw, 1200px"
type="image/webp"
/>
<img
src={contentfulImage(imageUrl, 800, 'jpg')}
alt={post.fields.featuredImage.fields.title}
loading="lazy"
/>
</picture>
)}
No build-time image processing needed. Just URL parameters. Nice.
SEO that actually works
Something I don’t have on my current Hugo site: proper per-page Open Graph metadata. Worth getting right from the start:
---
// src/components/SEO.astro
interface Props {
title: string
description?: string
image?: string
lang?: string
}
const {
title,
description = "Engineering Manager, Software Engineer, Tech Coach",
image,
lang = 'en'
} = Astro.props
const siteTitle = 'Oltion Zefi'
const fullTitle = title === siteTitle ? title : `${title} | ${siteTitle}`
---
<title>{fullTitle}</title>
<meta name="description" content={description} />
<link rel="canonical" href={Astro.url.href} />
<meta property="og:title" content={fullTitle} />
<meta property="og:description" content={description} />
<meta property="og:url" content={Astro.url.href} />
{image && <meta property="og:image" content={image} />}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={fullTitle} />
{image && <meta name="twitter:image" content={image} />}
Drop this into your layout and every page gets proper social sharing tags:
---
// src/layouts/Base.astro
import SEO from '../components/SEO.astro'
const { title, description, image, lang } = Astro.props
---
<!DOCTYPE html>
<html lang={lang || 'en'}>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<SEO title={title} description={description} image={image} />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
</head>
<body>
<slot />
</body>
</html>
For blog posts, the excerpt becomes the description:
<Layout
title={post.fields.title}
description={post.fields.excerpt}
image={post.fields.featuredImage
? `https:${post.fields.featuredImage.fields.file.url}?w=1200&fm=jpg`
: undefined}
>
Sitemap
One command:
npx astro add sitemap
Config:
import { defineConfig } from 'astro/config'
import sitemap from '@astrojs/sitemap'
export default defineConfig({
output: 'static',
site: 'https://oltionzefi.com',
integrations: [
sitemap({
i18n: {
defaultLocale: 'en',
locales: { en: 'en', de: 'de' }
}
})
]
})
And a robots.txt in public/:
User-agent: *
Allow: /
Sitemap: https://oltionzefi.com/sitemap-index.xml
RSS feed
Something I’ve been meaning to add:
npm install @astrojs/rss
// src/pages/rss.xml.ts
import rss from '@astrojs/rss'
import { getBlogPosts } from '../lib/contentful'
export async function GET(context) {
const posts = await getBlogPosts()
return rss({
title: 'Oltion Zefi - Blog',
description: 'My latest posts and articles',
site: context.site,
items: posts.map(post => ({
title: post.fields.title,
pubDate: new Date(post.fields.date),
description: post.fields.excerpt,
link: `/en/blog/${post.fields.slug}/`
}))
})
}
That’s it. RSS done.
Previewing drafts
A simple workflow: toggle a preview flag and see unpublished content:
{
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "CONTENTFUL_PREVIEW=true astro dev"
}
}
Run npm run preview, see drafts locally. For a personal site, that’s plenty. No need for a fancy preview deployment.
Keep it fast
A couple of things to keep build times short:
Fetch all posts once and pass them as props — don’t make individual API calls per page:
---
export async function getStaticPaths() {
const posts = await getBlogPosts()
return posts.map(post => ({
params: { slug: post.fields.slug },
props: { post } // no second fetch needed
}))
}
---
And run astro check before building to catch issues early without waiting for a full build.
The output
After all this, the build should produce about 48 HTML files and a single CSS file. No JavaScript bundles. PageSpeed loves this.
What’s next
The build is lean and production-ready. Next in Part 5: putting it in a Docker container with Nginx and setting up automatic deployments.
Keep pushing forward and savor every step of your coding journey.
