+1 (415) 779-8456

One Codebase, Twenty Gatsby Sites: Themes, Component Shadowing, and a Monorepo That Scales

Agencies and in-house platform teams rarely run one Gatsby site. They run eight, or twenty: a marketing site, a docs site, a careers microsite, and a dozen near-identical regional or client builds. Each one was forked from the last, and each one now has its own slightly different gatsby-config.js, its own pinned sharp, and its own copy of a header component that diverged eighteen months ago.

That was merely annoying while Gatsby shipped regular releases. Now that the framework is effectively frozen and every upgrade is a hand-managed patch, it is expensive: every Node bump, every CVE, every adapter change has to be applied N times. The fix is to stop treating the sites as N codebases and start treating them as one core plus N thin configurations. Gatsby has a first-class mechanism for exactly that — themes and component shadowing — and it is one of the better-designed parts of the framework that almost nobody used.

This tutorial builds that setup: a workspace monorepo, a shared theme package, per-site overrides via shadowing, and the CI arrangement that keeps twenty builds from melting your runners.

When this is worth doing

Be honest about the threshold. Consolidating is worth it when:

  • You maintain four or more Gatsby sites that share a visual language or content model.
  • The sites are on the same major version (all Gatsby 5, ideally same minor).
  • A security or Node upgrade currently means opening four-plus PRs by hand.

It is not worth it when the sites genuinely differ (different CMS, different page types, one is really an app), or when you have already committed to migrating off Gatsby within a quarter. In that case do the migration; do not refactor something you are about to delete.

1. The workspace layout

Use npm or pnpm workspaces. No Nx or Turborepo required to start — add them later if build orchestration actually hurts.

repo/
  package.json          # workspaces root
  packages/
    gatsby-theme-core/  # the shared theme
      package.json
      gatsby-config.js
      gatsby-node.js
      src/
        components/
        templates/
  sites/
    acme/
      package.json
      gatsby-config.js
      src/gatsby-theme-core/   # shadowed overrides
    globex/
    initech/

Root package.json:

{
  "name": "static-sites",
  "private": true,
  "workspaces": ["packages/*", "sites/*"],
  "scripts": {
    "build:acme": "npm run build -w sites/acme",
    "clean": "rimraf sites/*/.cache sites/*/public"
  }
}

A workspace install symlinks gatsby-theme-core into each site's node_modules, so edits to the theme are live in every site's dev server with no publish step. That alone removes most of the copy-paste.

2. The theme package

A Gatsby theme is just a plugin that happens to ship gatsby-config.js, gatsby-node.js, and components. Nothing exotic:

// packages/gatsby-theme-core/package.json
{
  "name": "gatsby-theme-core",
  "version": "1.0.0",
  "main": "index.js",
  "peerDependencies": {
    "gatsby": "^5.13.0",
    "react": "^18.0.0 || ^19.0.0",
    "react-dom": "^18.0.0 || ^19.0.0"
  },
  "dependencies": {
    "gatsby-plugin-image": "^3.13.0",
    "gatsby-plugin-sharp": "^5.13.0",
    "gatsby-source-filesystem": "^5.13.0",
    "gatsby-transformer-remark": "^6.13.0"
  }
}

Keep gatsby, react, and react-dom as peer dependencies. If the theme depends on them directly you get two copies of React in the bundle and hooks throw at hydration — the single most common failure when people first build a theme.

index.js can be empty (module.exports = {}); Gatsby only requires the file to exist.

The theme config takes options so each site can point at its own content and metadata:

// packages/gatsby-theme-core/gatsby-config.js
const path = require('node:path');

module.exports = (options = {}) => {
  const { contentPath = 'content', basePath = '/', siteUrl } = options;
  return {
    siteMetadata: { siteUrl },
    plugins: [
      'gatsby-plugin-image',
      'gatsby-plugin-sharp',
      'gatsby-transformer-sharp',
      {
        resolve: 'gatsby-source-filesystem',
        options: { name: 'content', path: path.resolve(contentPath) },
      },
      'gatsby-transformer-remark',
      'gatsby-plugin-sitemap',
    ].filter(Boolean),
  };
};

Exporting a function rather than an object is what makes a theme configurable. path.resolve(contentPath) resolves against the site's working directory, which is what you want: each site keeps its own content/.

Page creation lives in the theme too:

// packages/gatsby-theme-core/gatsby-node.js
const path = require('node:path');

exports.createPages = async ({ graphql, actions, reporter }, options) => {
  const { basePath = '/' } = options;
  const { data, errors } = await graphql(`
    {
      allMarkdownRemark(sort: { frontmatter: { date: DESC } }) {
        nodes { id frontmatter { slug } }
      }
    }
  `);
  if (errors) { reporter.panicOnBuild('theme createPages failed', errors); return; }

  const template = require.resolve('./src/templates/page.js');
  for (const node of data.allMarkdownRemark.nodes) {
    actions.createPage({
      path: path.posix.join(basePath, node.frontmatter.slug),
      component: template,
      context: { id: node.id },
    });
  }
};

Note require.resolve('./src/templates/page.js') rather than a string path. That is what makes the template shadowable — Gatsby resolves it through the theme's module graph, so a site can replace it.

3. Consuming the theme in a site

// sites/acme/gatsby-config.js
module.exports = {
  siteMetadata: {
    title: 'Acme',
    description: 'Industrial supplies since 1949',
  },
  plugins: [
    {
      resolve: 'gatsby-theme-core',
      options: {
        contentPath: 'content',
        basePath: '/',
        siteUrl: 'https://www.acme.example',
      },
    },
  ],
};

Three lines of real configuration. The site's package.json depends on gatsby-theme-core, gatsby, react, react-dom — and nothing else unless it genuinely needs something else.

Run it:

npm install
npm run develop -w sites/acme

4. Component shadowing: the per-site escape hatch

Shadowing is the feature that makes this survivable. Any file at packages/gatsby-theme-core/src/components/header.js can be replaced, for one site only, by creating sites/acme/src/gatsby-theme-core/components/header.js. Same path, under src/<theme-package-name>/. No config, no registration.

// sites/acme/src/gatsby-theme-core/components/header.js
import React from 'react';
import { Link } from 'gatsby';

export default function Header() {
  return (
    <header className="acme-header">
      <Link to="/"><img src="/acme-logo.svg" alt="Acme" width={120} height={32} /></Link>
      <nav>
        <Link to="/catalog">Catalog</Link>
        <Link to="/contact">Contact</Link>
      </nav>
    </header>
  );
}

The important discipline: extend, don't replace, wherever possible. You can import the theme's original component inside your shadowed file and wrap it:

// sites/globex/src/gatsby-theme-core/components/layout.js
import React from 'react';
import Layout from 'gatsby-theme-core/src/components/layout';
import Banner from '../../components/regulatory-banner';

export default function GlobexLayout({ children, ...props }) {
  return (
    <Layout {...props}>
      <Banner />
      {children}
    </Layout>
  );
}

Importing gatsby-theme-core/src/components/layout by its package path gets you the original, not the shadow — Gatsby does not re-shadow that resolution, so this does not recurse. A wrapped shadow keeps inheriting theme fixes; a wholesale copy stops inheriting the day you write it.

Rules of thumb we enforce on client repos:

  • Shadow presentational components freely (header, footer, hero, colour tokens).
  • Shadow templates only with a comment explaining why, and review them quarterly.
  • Never shadow gatsby-node.js logic. If a site needs different page creation, the theme needs an option — add the option.
  • Keep a SHADOWS.md per site listing every shadowed file and the reason. Shadows are invisible in the theme's code; without a list, nobody knows what is overridden until a theme change silently does nothing.

A quick audit script you can put in CI:

#!/usr/bin/env bash
# scripts/audit-shadows.sh — list every shadowed file per site
for dir in sites/*/src/gatsby-theme-core; do
  site=$(echo "$dir" | cut -d/ -f2)
  count=$(find "$dir" -type f 2>/dev/null | wc -l)
  echo "== $site: $count shadowed file(s)"
  find "$dir" -type f 2>/dev/null | sed "s|$dir/|   |"
done

If a site is over roughly fifteen shadows, it is not a variant of the theme any more; either pull the differences up into theme options or let that site have its own package.

5. Theme options versus shadowing: choosing correctly

The difference is…Use
Content source, base path, URLs, feature flagsTheme options
Colours, fonts, spacingDesign tokens in options or a CSS custom-property file
One site's header has a login buttonShadow the header
One site needs an extra page typeTheme option + conditional createPage, not a shadow
One site needs a different CMSA second theme (gatsby-theme-contentful), composed alongside

Themes compose: a site can list gatsby-theme-core and gatsby-theme-commerce together, and shadowing works against each package independently. That is a better factoring than one god-theme with fifteen boolean options.

6. Upgrading twenty sites once

This is the payoff. The dependency surface that used to live in twenty package.json files now lives in one. Pin versions in the theme, and let workspaces hoist.

For the transitive dependencies you cannot control — the perennial problem with a frozen framework — put overrides in the root package.json so every workspace inherits them:

{
  "overrides": {
    "webpack": "5.97.1",
    "cookie": "0.7.2",
    "path-to-regexp": "1.9.0"
  }
}

Then verify the whole fleet in one command before merging:

npm audit --omit=dev --audit-level=high
npm ls react react-dom gatsby --workspaces --depth=0
npm run build --workspaces --if-present

npm ls react --workspaces is the check that catches the duplicate-React problem before it reaches a browser: every site should print a single deduped version.

7. CI without burning an hour per push

Twenty Gatsby builds in series is a coffee break; in parallel with cold caches it is a bill. Two changes matter.

Build only what changed. Compute the affected sites from the diff — theme changes hit everything, site changes hit one:

# .github/workflows/build.yml
jobs:
  affected:
    runs-on: ubuntu-latest
    outputs:
      sites: ${{ steps.calc.outputs.sites }}
    steps:
      - uses: actions/checkout@v4
        with: { fetch-depth: 0 }
      - id: calc
        run: |
          CHANGED=$(git diff --name-only origin/main...HEAD)
          if echo "$CHANGED" | grep -q '^packages/'; then
            SITES=$(ls sites | jq -R . | jq -sc .)
          else
            SITES=$(echo "$CHANGED" | grep '^sites/' | cut -d/ -f2 | sort -u | jq -R . | jq -sc .)
          fi
          echo "sites=${SITES:-[]}" >> "$GITHUB_OUTPUT"

  build:
    needs: affected
    if: needs.affected.outputs.sites != '[]'
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      max-parallel: 4
      matrix:
        site: ${{ fromJSON(needs.affected.outputs.sites) }}
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: npm }
      - run: npm ci
      - uses: actions/cache@v4
        with:
          path: |
            sites/${{ matrix.site }}/.cache
            sites/${{ matrix.site }}/public
          key: gatsby-${{ matrix.site }}-${{ hashFiles('package-lock.json') }}-${{ github.sha }}
          restore-keys: gatsby-${{ matrix.site }}-${{ hashFiles('package-lock.json') }}-
      - run: npm run build -w sites/${{ matrix.site }}
        env:
          CI: true
          NODE_OPTIONS: --max-old-space-size=4096

Cache per site, keyed on the lockfile. Gatsby's .cache is not portable between sites, and it must be invalidated when dependencies change — hence the lockfile hash in the key. Note the fallback restore-keys so a new commit still warms from the previous build. If a build starts behaving strangely after a theme change, the first debugging step is always deleting .cache and public; when in doubt, add a manual "clean build" workflow dispatch input rather than letting people guess.

One more guard worth having: after each build, assert the output is not empty and the homepage contains real HTML. A theme change that breaks one site's page creation otherwise produces a green build and an empty public/.

test -s "sites/$SITE/public/index.html" || { echo "empty build"; exit 1; }
grep -q "<main" "sites/$SITE/public/index.html" || { echo "no rendered content"; exit 1; }

8. Migrating existing sites into the theme, safely

Do not big-bang this. The order that works:

  1. Create the theme package with nothing in it but gatsby-config.js returning an empty plugin array. Add it to one site. Build. Confirm nothing changed.
  2. Move plugins from that site's config into the theme, one group at a time, building after each. Sharp and filesystem sourcing first — they are the noisiest.
  3. Move components into the theme. Immediately shadow them in the site with a re-export (export { default } from 'gatsby-theme-core/src/components/header'), then delete the shadow once you confirm nothing else imported the old path.
  4. Onboard site two. This is where the real work is, because site two's differences are what defines your options API. Expect to refactor the theme.
  5. Sites three onward take hours, not days.
  6. Keep a byte-level diff of the built output across each step — diff -rq public-before public-after — so "refactor only" really is refactor only.

Step 6 is not optional. Every consolidation project we have rescued went wrong at the point someone bundled a redesign into the migration and lost the ability to tell a regression from an intended change.

What you get

One dependency graph. One place to apply a Node or CVE bump. One CI pipeline whose cost scales with what changed rather than with how many sites you own. And per-site flexibility that does not require forking, because shadowing gives each site a legitimate override path with a paper trail.

It also makes the eventual move off Gatsby dramatically cheaper: when the shared behaviour lives in one package with a documented options API and a known list of per-site deviations, porting it to Astro, Next, or anything else is a single well-understood piece of work instead of twenty archaeological digs.

If you are maintaining a fleet of Gatsby sites and the upgrade tax is getting painful, get in touch — fleet consolidation and Gatsby theme architecture are a large part of what we do.