Building a Production-Grade Bilingual Site with Astro: Architecture and Implementation

• by Tobias Schäfer • 8 min read

Building a bilingual static site requires more than just duplicating content—it demands a robust architecture that handles type-safe schemas, intelligent routing, efficient caching, and seamless deployment. This post explores the technical implementation of a production-grade bilingual portfolio using Astro’s content collections, complete with Docker containerization and nginx optimization strategies.

By the end of this guide, you’ll understand how to architect a type-safe, performant bilingual site with Astro that scales from development to production.

Technical Requirements

A production bilingual site needs to solve several architectural challenges:

  • Type safety: Validate content schemas at build time to catch errors early
  • i18n routing: Handle language-specific URLs without manual route configuration
  • Performance: Optimize caching strategies for static assets vs dynamic content
  • Deployment: Containerized builds with minimal image size and maximum security
  • Developer experience: Fast builds, hot reload, and clear error messages

The solution combines Astro’s content collections for type-safe content management, native i18n for routing, Docker multi-stage builds for deployment, and carefully tuned nginx configuration for production performance.

Technical Foundation

Astro Content Collections with i18n

The architecture starts with Astro’s content collections, which provide type-safe content management with Zod schema validation:

// src/content.config.ts
import { defineCollection, z } from 'astro:content';

const portfolio = defineCollection({
  schema: z.object({
    title: z.string(),
    description: z.string(),
    image: z.object({
      url: z.string(),
      alt: z.string()
    }),
    technologies: z.array(z.string()),
    projectUrl: z.string().url().optional(),
    githubUrl: z.string().url().optional(),
    featured: z.boolean().default(false),
  }),
});

export const collections = { portfolio };

This schema validates every portfolio item before build time, catching errors like missing required fields or invalid URLs early in the development process.

Bilingual Content Structure

The directory structure separates content by language while keeping it organized:

src/content/
├── portfolio/
│   ├── en/
│   │   ├── mitkai.md
│   │   └── plugin-installer.md
│   └── de/
│       ├── kai.md
│       └── plugin-installer.md
└── blog/
    ├── en/
    └── de/

Astro’s i18n configuration handles routing automatically:

// astro.config.mjs
export default defineConfig({
  i18n: {
    locales: ["de", "en"],
    defaultLocale: "en",
    routing: {
      prefixDefaultLocale: false
    }
  }
});

This creates clean URLs:

  • English (default): /portfolio/mitkai
  • German: /de/portfolio/kai

Content Querying and Filtering

Astro’s getCollection API provides a powerful filtering mechanism for language-specific content:

// src/pages/index.astro
import { getCollection } from 'astro:content';

// Get only English portfolio items
const englishProjects = await getCollection('portfolio', ({ id, data }) => {
  return id.startsWith('en/') && data.featured;
});

// Get German blog posts, excluding drafts
const germanPosts = await getCollection('blog', ({ id, data }) => {
  return id.startsWith('de/') && data.draft !== true;
});

// Sort by date
const sortedPosts = germanPosts.sort(
  (a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()
);

The id field follows the pattern {language}/{filename} (e.g., en/mitkai.md), making language filtering straightforward with startsWith().

Dynamic Route Generation

Astro generates routes dynamically for each content item using getStaticPaths:

// src/pages/portfolio/[...slug].astro
import { getCollection, type CollectionEntry } from 'astro:content';

export async function getStaticPaths() {
  const portfolio = await getCollection('portfolio', ({ id }) =>
    id.startsWith('en/')
  );

  return portfolio.map(entry => ({
    params: { slug: entry.slug },
    props: { entry },
  }));
}

type Props = {
  entry: CollectionEntry<'portfolio'>;
};

const { entry } = Astro.props;
const { Content } = await entry.render();

This pattern:

  1. Fetches all English portfolio items at build time
  2. Creates a route for each item automatically
  3. Provides type-safe props with full TypeScript support
  4. Renders markdown content with entry.render()

For the German version (/de/portfolio/[...slug].astro), simply change the filter to id.startsWith('de/').

Advanced Schema Patterns

Extending Schemas with Computed Fields

Astro content collections support computed fields through remark plugins. Here’s how to add reading time to blog posts:

// remark-reading-time.mjs
import getReadingTime from 'reading-time';
import { toString } from 'mdast-util-to-string';

export function remarkReadingTime() {
  return function (tree, { data }) {
    const textOnPage = toString(tree);
    const readingTime = getReadingTime(textOnPage);
    data.astro.frontmatter.minutesRead = readingTime.text;
  };
}

Configure in astro.config.mjs:

import { remarkReadingTime } from './remark-reading-time.mjs';

export default defineConfig({
  markdown: {
    remarkPlugins: [remarkReadingTime],
  },
});

Update the schema to include the computed field:

const blog = defineCollection({
  schema: z.object({
    // ... other fields
    minutesRead: z.string().optional(),
  }),
});

Image Optimization with Astro Assets

For optimal image handling, use Astro’s image service:

import { z, defineCollection } from 'astro:content';

const portfolio = defineCollection({
  schema: ({ image }) => z.object({
    title: z.string(),
    description: z.string(),
    image: z.object({
      url: image(), // Validates image exists and optimizes it
      alt: z.string(),
    }),
    technologies: z.array(z.string()),
    projectUrl: z.string().url().optional(),
    githubUrl: z.string().url().optional(),
    featured: z.boolean().default(false),
  }),
});

The image() helper:

  • Validates image paths at build time
  • Enables automatic optimization
  • Provides type-safe image props
  • Supports responsive images with <Picture> component

Production Deployment

Docker Multi-Stage Build

The Dockerfile uses a two-stage build for optimal image size:

# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 2: Production
FROM macbre/nginx-brotli:latest
RUN apk add --no-cache curl
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s CMD curl --fail http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

Result: 30MB production image with Brotli compression support.

Intelligent Caching Strategy

The nginx configuration implements tiered caching based on content type:

# Astro hashed assets (filenames change on update)
location ~ ^/_astro/.*\.(js|css)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Images and fonts
location ~* \.(jpg|png|svg|webp|woff2)$ {
    expires 30d;
    add_header Cache-Control "public, max-age=2592000";
}

# HTML pages (updates visible quickly)
location ~* \.html$ {
    expires 5m;
    add_header Cache-Control "public, max-age=300, must-revalidate";
}

Key insight: Astro automatically hashes asset filenames (main.abc123.js), so they can be cached forever. HTML pages cache for only 5 minutes, ensuring new content appears quickly while maintaining excellent performance for returning visitors.

Security Hardening

Production configuration includes comprehensive security:

# Rate limiting (10 requests/second per IP)
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;

# Security headers
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'...";
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

# Deny access to hidden files
location ~ /\. { deny all; }

Performance Results

After optimization:

  • Update visibility: New content appears within 5 minutes (was 1 hour)
  • Compression: Brotli achieves ~50% reduction (vs ~30% with gzip)
  • Cache hit rate: ~95% for returning visitors
  • Page load: Sub-second for cached content
  • Image size: ~30MB Docker image (including nginx + content)

SEO and Sitemap Generation

Astro automatically generates sitemaps with i18n support when configured:

// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';

export default defineConfig({
  site: 'https://example.com',
  i18n: {
    locales: ["de", "en"],
    defaultLocale: "en",
  },
  integrations: [
    sitemap({
      i18n: {
        defaultLocale: 'en',
        locales: {
          en: 'en-US',
          de: 'de-DE',
        },
      },
    }),
  ],
});

This generates:

  • /sitemap-index.xml - Main sitemap index
  • /sitemap-0.xml - Pages sitemap with hreflang tags

Example sitemap entry with language alternates:

<url>
  <loc>https://example.com/portfolio/mitkai</loc>
  <xhtml:link rel="alternate" hreflang="en-US"
    href="https://example.com/portfolio/mitkai"/>
  <xhtml:link rel="alternate" hreflang="de-DE"
    href="https://example.com/de/portfolio/kai"/>
</url>

Language Switcher Component

Implement a language switcher that maps between locales:

// src/components/LanguageSwitcher.astro
---
import { getRelativeLocaleUrl } from 'astro:i18n';

interface Props {
  currentLocale: string;
  currentPath: string;
}

const { currentLocale, currentPath } = Astro.props;

// Map paths between languages
const pathMap = {
  'portfolio/mitkai': 'portfolio/kai',
  'portfolio/plugin-installer': 'portfolio/plugin-installer',
};

const translatedPath = currentLocale === 'en'
  ? pathMap[currentPath] || currentPath
  : Object.keys(pathMap).find(key => pathMap[key] === currentPath) || currentPath;

const alternateLocale = currentLocale === 'en' ? 'de' : 'en';
const alternateUrl = getRelativeLocaleUrl(alternateLocale, translatedPath);
---

<a href={alternateUrl} hreflang={alternateLocale}>
  {alternateLocale === 'en' ? 'English' : 'Deutsch'}
</a>

Docker Build Optimization

Layer Caching Strategy

The key to fast Docker builds is intelligent layer caching:

# Stage 1: Dependencies (cached unless package.json changes)
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production

# Stage 2: Build (cached unless source changes)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Stage 3: Production
FROM macbre/nginx-brotli:latest
RUN apk add --no-cache curl
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s \
  CMD curl --fail http://localhost/ || exit 1
CMD ["nginx", "-g", "daemon off;"]

Critical point: Content changes don’t affect package.json, so Docker may reuse the build layer. Always use --no-cache when deploying new content:

docker-compose build --no-cache && docker-compose up -d

Monitoring and Observability

Health Checks

The Docker health check monitors nginx internally:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD curl --fail http://localhost/ || exit 1

Check status:

docker inspect --format='{{.State.Health.Status}}' container_name

Performance Monitoring

Monitor cache effectiveness:

# View nginx access logs with cache status
docker exec container_name tail -f /var/log/nginx/access.log

# Filter for cache misses
docker exec container_name grep "MISS" /var/log/nginx/access.log

Add cache status header in nginx.conf for debugging:

add_header X-Cache-Status $upstream_cache_status always;

Build Time Optimization

Track Astro build performance:

# Add to package.json
"scripts": {
  "build": "astro build",
  "build:verbose": "DEBUG=* astro build"
}

Analyze bundle size:

npm run build
du -sh dist/*

Expected sizes:

  • HTML pages: ~10-50 KB each
  • Hashed JS/CSS: ~100-500 KB total
  • Images: Variable (optimize with tools like Sharp)

Technology Stack

Core Framework:

  • Astro 5.x: Static site generation with islands architecture
  • TypeScript 5.x: Type-safe development
  • Zod 3.x: Runtime schema validation
  • Tailwind CSS v4: Utility-first styling

Build & Deployment:

  • Vite: Fast HMR and optimized builds
  • Docker 24+: Containerization
  • nginx 1.25+ with Brotli: Production web server
  • Node.js 20 LTS: Build environment

Performance:

  • Brotli compression: ~50% size reduction
  • 5-minute HTML cache: Fast content updates
  • 1-year asset cache: Optimal repeat visits
  • ETag validation: Efficient revalidation

Conclusion

Building a production-grade bilingual Astro site combines several technical disciplines: type-safe content collections with Zod schemas, intelligent i18n routing, optimized Docker builds with multi-stage patterns, and carefully tuned nginx caching strategies. The result is a fast, secure, and maintainable static site that handles two languages seamlessly.

Key Technical Takeaways:

  • Type safety matters: Zod schemas catch errors at build time, not runtime
  • i18n is built-in: Astro’s native i18n handles routing automatically
  • Cache strategically: Hashed assets cache forever, HTML for 5 minutes
  • Build efficiently: Multi-stage Docker builds minimize image size
  • Deploy correctly: Always use --no-cache for content updates

This architecture serves thousands of monthly visitors with sub-second load times, 95%+ cache hit rates, and comprehensive security headers—all from a 30MB Docker image.


Tech Stack: Astro 5 · TypeScript · Docker · nginx + Brotli · Tailwind CSS v4 · Zod