Skip to content

Static Builds with ApostropheCMS + Astro

This tutorial explains how to run ApostropheCMS as your content backend while generating a fully static Astro frontend at build time. The final output is static HTML, CSS, JS, and media files that can be deployed to any static hosting platform — no Node.js server required for the frontend.

This is different from the default SSR setup covered in the rest of this tutorial series, where the Astro frontend runs as a live server that proxies requests to ApostropheCMS. In static mode, all content is fetched from ApostropheCMS once at build time and baked into the output. The tradeoff is that content changes require a new build and deploy, but the result is a fast, simple, and inexpensive production site.

How Static Mode Works

When you run a static build, the Astro build process:

  1. Requests URL metadata from ApostropheCMS — this includes all pages, piece show page URLs, and any filter and pagination URLs generated by your piece index pages.
  2. Renders all routes returned by getStaticPaths(), fetching the content for each from ApostropheCMS.
  3. Copies literal backend-generated files — such as dynamic CSS from ApostropheCMS's Styles module, robots.txt, and sitemaps — into the static dist/ output. Your Astro project's public/ folder is not modified.
  4. Copies attachment files (uploaded images, PDFs, etc.) into the static output so the site can serve them without a live backend.

Your ApostropheCMS backend must be running and accessible during the entire build. Once the build is complete, the static output is self-contained.

Backend Configuration

The backend requires a small amount of configuration to support static builds. These changes tell ApostropheCMS to generate the kind of URL metadata that the static build process needs.

1. Enable Static URL Behavior

The @apostrophecms/url module controls how ApostropheCMS generates URLs for pages and pieces. By default it generates URLs suited for a live SSR environment. Setting static: true switches it to generate path-based filter and pagination URLs, and enables the URL metadata collection endpoint that the Astro build process calls at build time.

Add or update the module in your backend:

javascript
export default {
  options: {
    static: true
  }
};
backend/modules/@apostrophecms/url/index.js

2. Set staticBaseUrl

ApostropheCMS needs to know the public origin of your static site so it can generate correct absolute URLs for links, canonical tags, and sitemaps. This is separate from baseUrl, which points to the ApostropheCMS backend itself.

Set staticBaseUrl in your app.js to the public URL where your static site will be hosted:

javascript
apostrophe({
  shortName: 'my-project',
  baseUrl: 'http://localhost:4321',
  staticBaseUrl: 'https://www.example.com',
  modules: {
    '@apostrophecms/url': {
      options: {
        static: true
      }
    }
  }
});
backend/app.js

This is separate from baseUrl, which should point to your Astro frontend — http://localhost:4321 in development, or your SSR staging URL in production. ApostropheCMS uses baseUrl as the public origin for things like login redirects and email links, and in an Astro integration that public origin is the Astro server, not the ApostropheCMS backend directly.

In production you will typically set staticBaseUrl via the APOS_STATIC_BASE_URL environment variable rather than hardcoding it, which makes it easier to manage across different environments.

3. Configure Piece Filters for Static Generation

If your piece index pages use filters — for example, filtering articles by category — you need to declare those filters using the piecesFilters option in your piece page module. This allows ApostropheCMS to enumerate each filter value as a separate static path, along with the pagination URLs for each, so they can be pre-rendered.

Without this configuration, filtered URLs will not be included in the static build and will return 404s on the deployed site.

Note: In static mode, only a single filter is supported. Filter combinations — for example, filtering by both category and author simultaneously — will not be enumerated as static paths and will return 404s on the deployed site. If your use case requires multiple filters, you will need to either limit the interface to one active filter at a time, or switch to an SSR deployment.

javascript
export default {
  extend: '@apostrophecms/piece-page-type',
  options: {
    piecesFilters: [
      { name: 'category' }
    ]
  }
};
backend/modules/article-page/index.js

You can read more about piecesFilters configuration in the Creating Pieces tutorial.

Frontend Configuration

The Astro frontend needs two changes to support static builds: switching the output mode in astro.config.mjs, and adding a getStaticPaths() export to your catch-all route.

1. Switch Astro Output Mode

By default, the Astro frontend runs in SSR mode (output: 'server'). For a static build you need to switch this to output: 'static' and remove the Node.js adapter, since there is no server to run.

Rather than maintaining two separate config files, the recommended approach is to use an environment variable to switch between modes. Setting APOS_BUILD=static before running the build command tells the config to switch to static output:

javascript
import { defineConfig } from 'astro/config';
import node from '@astrojs/node';
import apostrophe from '@apostrophecms/apostrophe-astro';

const isStatic = process.env.APOS_BUILD === 'static';

export default defineConfig({
  output: isStatic ? 'static' : 'server',
  adapter: isStatic ? undefined : node({ mode: 'standalone' }),
  integrations: [
    apostrophe({
      aposHost: 'http://localhost:3000',
      widgetsMapping: './src/widgets/index.js',
      templatesMapping: './src/templates/index.js'
    })
  ]
});
frontend/astro.config.mjs

When APOS_BUILD is not set, the config behaves exactly as before — SSR mode with the Node.js adapter — so your development workflow and SSR staging environment are unaffected.

2. Add getStaticPaths to Your Catch-All Route

In SSR mode, [...slug].astro handles every incoming request dynamically. In static mode, Astro needs to know all the routes upfront so it can pre-render each one. This is done by exporting a getStaticPaths() function.

The apostrophe-astro package provides a getAllStaticPaths() helper that calls the URL metadata endpoint on your ApostropheCMS backend and returns the full list of paths to render:

astro
---
import { getAllStaticPaths } from '@apostrophecms/apostrophe-astro/lib/static.js';
import { getAposHost } from '@apostrophecms/apostrophe-astro/helpers';
import aposPageFetch from '@apostrophecms/apostrophe-astro/lib/aposPageFetch.js';

export async function getStaticPaths() {
  return getAllStaticPaths({
    aposHost: getAposHost(),
    aposExternalFrontKey: import.meta.env.APOS_EXTERNAL_FRONT_KEY
  });
}

const aposData = await aposPageFetch(Astro.request);
---
frontend/src/pages/[...slug].astro

The rest of your [...slug].astro file remains unchanged — aposPageFetch works the same way in both SSR and static modes.

Build Commands and Environment Variables

To trigger a static build, add a dedicated script to your frontend package.json. The key difference from the regular build is the APOS_BUILD=static prefix:

json
{
  "scripts": {
    "dev": "cross-env APOS_EXTERNAL_FRONT_KEY=dev astro dev",
    "build": "astro build",
    "build:static": "APOS_BUILD=static APOS_EXTERNAL_FRONT_KEY=dev astro build",
    "preview:static": "APOS_BUILD=static astro preview --port 4000"
  }
}

After running build:static, use preview:static to serve the output locally in a production-like static environment. The APOS_BUILD=static flag is required here as well — without it, Astro's config would attempt to load the SSR adapter, which is not compatible with astro preview on a static build. If your project uses a path prefix via APOS_PREFIX, the preview command will respect that as well. The explicit port 4000 avoids a conflict with the default 4321 used by astro dev, making it easy to run both side by side when comparing builds.

frontend/package.json

In production you will set these via your hosting platform's environment variable configuration rather than inline in the script. The following variables are relevant to static builds:

  • APOS_BUILD=static — switches astro.config.mjs to static output mode
  • APOS_EXTERNAL_FRONT_KEY — required; authenticates the frontend with the ApostropheCMS backend during the build
  • APOS_HOST — the URL of your ApostropheCMS backend; defaults to http://localhost:3000 if not set
  • APOS_PREFIX — set this if your site is hosted under a path prefix (see Non-Root Hosting below)
  • APOS_STATIC_BASE_URL — the public origin of your static site; required for production to generate correct absolute URLs
  • APOS_SKIP_ATTACHMENTS — set to skip copying attachment files into the static output, useful if your attachments are served from a CDN
  • APOS_ATTACHMENT_SIZES — comma-separated list of image sizes to include in the static output
  • APOS_ATTACHMENT_SKIP_SIZES — comma-separated list of image sizes to exclude
  • APOS_ATTACHMENT_SCOPE — controls which attachments are copied; see the package documentation for options

Static-Safe Template Helpers

When building for static output, some of the patterns used for pagination and filtering in SSR mode need to be adjusted. The apostrophe-astro package provides helpers in @apostrophecms/apostrophe-astro/helpers that work correctly in both SSR and static modes:

  • buildPageUrl(aposData, pageNumber) — generates a pagination URL for the given page number. Use this instead of manually assembling query strings, as the static and SSR modes use different URL structures for pagination.
  • getFilterBaseUrl(aposData) — returns a base URL that is safe to append filter parameters to, accounting for any prefix or static URL configuration.
  • getAposHost() — resolves the ApostropheCMS backend host from environment variables; use this in server-side frontmatter code when making direct API calls.
  • aposFetch() — a thin wrapper around fetch for making server-side requests to ApostropheCMS APIs, with host and auth header handling built in.

Filters and Pagination in Static Mode

If you followed the Creating Pieces tutorial, you may be using piecesFilters and currentPage/totalPages from aposData to build filter links and pagination. These patterns work in static mode, but there are a couple of things to be aware of.

For filter links, prefer aposData.filters (derived from req.data.filters) over the legacy aposData.piecesFilters pattern where possible. This provides filter metadata and pre-built URLs that are consistent with how static paths are generated.

Note that static mode only supports a single active filter at a time. Combined filters will not have pre-rendered paths and will return 404s. See the note in Backend Configuration above.

For pagination, use the buildPageUrl helper rather than manually constructing URLs with query parameters. In SSR mode pagination uses query strings; in static mode it uses path segments. The helper handles both cases transparently.

Widget Behavior in Static Output

In SSR mode, widgets can make client-side fetch calls to ApostropheCMS API routes — for example, the video widget fetches oEmbed data from /api/v1/@apostrophecms/oembed/query. On a static site, the ApostropheCMS backend is not available at runtime, so these calls will fail.

If you have widgets that rely on runtime API calls, you will need to move that data fetching to the Astro server/frontmatter code at build time. The data can then be passed into the widget template as props or embedded into the HTML as data-* attributes for any client-side JavaScript to read. The Creating Widgets tutorial covers API-driven widget patterns in more detail.

Non-Root Hosting (GitHub Pages and Similar)

Some hosting platforms serve sites from a path prefix rather than the root of a domain — for example, GitHub Pages often serves project sites at https://username.github.io/my-repo/ rather than https://username.github.io/. This requires coordinated configuration on both the backend and frontend.

On the backend, set prefix to your path (for example /my-repo) and set staticBaseUrl to the origin only (for example https://username.github.io), without the path. ApostropheCMS will combine them when generating absolute URLs.

On the frontend, set base: '/my-repo' in your astro.config.mjs. This tells Astro to prefix all asset and page URLs with the path.

The value of prefix on the backend and base on the frontend must be identical, otherwise links between pages will break.

What to Watch Out For

  1. The backend must stay online for the entire static build. If your ApostropheCMS instance goes down mid-build, the build will fail or produce incomplete output.
  2. Content changes require a new build and deploy. Unlike the SSR setup where content updates are visible immediately, a static site is a snapshot. Plan for a publish workflow — whether that's a manual trigger, a scheduled build, or a webhook from ApostropheCMS.
  3. Preview and in-context editing are not available on the static site. The ApostropheCMS admin UI only works in the SSR environment. Your editors will need access to a separate SSR staging environment to create and edit content before publishing.
  4. Static mode can significantly increase build time with large sites, many filter combinations, or large attachment sets. Test your build times early so they don't become a surprise in production.

Next Steps