|

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.