← blog Leggi in italiano
EN 7 min read

Shopify theme monorepo: sharing Liquid across multiple stores

How I structured a monorepo to manage multiple Shopify themes with shared snippets, without code duplication and without third-party tools — just Node.js, a bash script, and a TOML config file.

shopifymonorepoliquidecommercetooling

Liquid has no import system. There’s no {% include 'package:snippet' %} — every file you use in a Shopify theme must live in that theme’s snippets/ folder. When you manage multiple stores with the same team, this inevitably leads to duplication: the same logic copied across 8 places, updated in 3.

The problem I solved: sharing Liquid snippets, sections, and assets across multiple distinct Shopify themes, while keeping the ability to override per store, without third-party services and without infrastructure complexity.

Monorepo structure

The repository has two types of workspaces:

monorepo/
├── packages/
│   ├── theme/          ← shared source (snippets, sections, assets)
│   └── scripts/        ← Node.js library for automation scripts
├── apps/
│   ├── brand-a/d2c/    ← Shopify theme for brand A
│   ├── brand-b/d2c/    ← Shopify theme for brand B
│   └── ...             ← N brands
└── shopify.theme.toml  ← maps all Shopify environments

packages/theme/ is the canonical source: snippets used by all brands live there. Each apps/brand-x/d2c/ is a complete Shopify theme — the thing the Shopify CLI knows and deploys. Syncing from packages/theme/ to each app directory happens via a script.

shopify.theme.toml as single source of truth

The Shopify CLI uses a TOML file to map environments to stores and paths:

[environments.brand-a-d2c]
theme = "139383570680"
store = "brand-a-production-store"
path = "apps/brand-a/d2c"

[environments.brand-b-d2c]
theme = "159628362054"
store = "brand-b-production"
path = "apps/brand-b/d2c"

I chose to use this file as the configuration for custom scripts as well — instead of maintaining a separate list of stores in a JSON file or env vars. When you add a brand, you add it once in the TOML and all tooling sees it automatically.

The TOML parser uses @iarna/toml:

const getStoresConfig = async () => {
  const data = await readFile(join(cwd, 'shopify.theme.toml'), { encoding: 'utf-8' });
  const { environments = {} } = toml.parse(data) || {};

  let envs = Object.entries(environments);

  if (!envs.length) {
    throw new Error(`No environments found in shopify.theme.toml`);
  }

  const environment = processArgv.e || processArgv.environment;

  if (environment) {
    envs = envs.filter(([env]) => env === environment);
  }

  return envs.map(([env, config]) => ({
    environment: env,
    ...config
  }));
};

The --environment flag filters to a single brand when you need to work on one specifically. Without it, all environments are returned.

The sync script

syncThemes.js copies files from packages/theme/ to every app directory:

const syncThemes = async () => {
  const storesConfig = await getStoresConfig();
  const cwd = process.cwd();
  const baseThemeFiles = join(cwd, 'packages', 'theme', '**', '*');
  const up = baseThemeFiles.split('/').filter(Boolean).length - 1;

  for (const { store, path: outDir = '' } of storesConfig) {
    if (!store || !outDir) {
      logger.error(`Missing store name or path for store: "${store}"`);
      continue;
    }

    logger.info(`Syncing theme for store: "${store}"`);

    copyfiles(
      [baseThemeFiles, outDir],
      { up, exclude: ['**/package.json', '.theme-check.yml'] },
      (error) => {
        if (error) throw error;
      }
    );
  }
};

copyfiles is a Node.js package that handles the glob and directory structure. The up parameter trims the source path prefix: without it, the copy would keep packages/theme/snippets/foo.liquid instead of snippets/foo.liquid inside the destination directory.

The command is just pnpm theme:sync and it propagates all shared files to all stores at once.

Pulling from Shopify previews and auto-commit

The reverse flow — syncing changes made via the Shopify theme editor back to the repo — is handled by updateRepoThemes.js plus a bash script:

#!/bin/bash

source "${0%/*}/require-clean-work-tree.sh"
require_clean_work_tree  # aborts if there are uncommitted changes

ENVIRONMENTS=("$@")
export HUSKY=0

git fetch && git checkout main && git pull

for ENVIRONMENT in "${ENVIRONMENTS[@]}"; do
  MATCH=$(pnpm theme:info -e $ENVIRONMENT | grep "Development Theme ID   Not set")

  if [[ -z "$MATCH" ]]; then
    pnpm theme:pull -e $ENVIRONMENT
    git add .
    git commit -m "chore($ENVIRONMENT): theme update" --no-verify
  else
    echo "Environment $ENVIRONMENT not found"
  fi
done

git push origin main
unset HUSKY

Key points:

  • require-clean-work-tree — verifies there are no local modifications before starting. Prevents losing uncommitted work during the pull.
  • HUSKY=0 — disables pre-commit hooks for auto-generated commits. Running lint on files pulled from Shopify serves no purpose.
  • theme:info | grep "Development Theme ID" — checks that the environment isn’t an active development theme before pulling. Prevents overwriting the wrong theme.
  • The commit is automatic with a conventional message (chore($ENVIRONMENT): theme update), so it ends up in the changelog generated by release-please.

The Node.js script that calls it reads environments from TOML and passes them as arguments to bash:

const environments = storesConfig.map(({ environment }) => environment);
const args = ['bin/update-repo-themes.sh', ...environments].filter(Boolean);
const childProcess = spawn('bash', args, { shell: true, stdio: 'inherit' });

Brand-specific overrides

The copy from packages/theme/ doesn’t delete files already present in the brand directory — copyfiles only overwrites files that exist in the source. Each brand can therefore have additional files in its own snippets/ that aren’t part of the shared theme.

The same applies to sections and templates: the shared theme provides the base, each brand adds or modifies independently. After each theme:sync, local brand modifications remain intact because they don’t exist in the shared source.

If you want a brand to use a different version of a shared snippet, the solution is simply to not copy that file — exclusions are handled by adding the pattern to exclude in syncThemes.js.

The shared snippet library

In packages/theme/snippets/ live the reusable components across all brands. A few examples that illustrate the pattern:

create-img-sizes.liquid — generates the sizes attribute for responsive images from a declarative config:

{%- render 'create-img-sizes', sizes: '1536=calc(25vw - 32px), 768=64px, 0=100vw, 1024=256px' -%}

It takes the string, splits it by breakpoint, sorts descending, and generates the correct media query sequence. Instead of writing (min-width: 1536px) calc(25vw - 32px), (min-width: 1024px) 256px, 100vw by hand in every template, the API is the config string.

content-with-widgets.liquid — parses custom markup embedded in article HTML:

[widget]type=product-card[&]handle=product-1[&]size=lg[/widget]

The snippet parses the content, extracts type and params for each widget, and delegates rendering to widget-serializer. It lets editors inject dynamic components into articles without touching code — useful when the CMS is the Shopify editor and you don’t have a headless CMS with structured blocks.

CI/CD pipeline

Each brand has its own GitHub Actions workflow. The reusable workflow:

  1. Runs theme:pull from the development environment on Shopify
  2. Runs theme:check (Liquid linting)
  3. On PR: deploys to a development theme and posts the preview link as a comment
  4. On version tag: deploys to the live theme

The interesting part is that a PR touching both shared files (packages/theme/) and a specific brand’s files only triggers that brand’s workflow. GitHub Actions path filters handle this: each workflow’s paths includes packages/theme/** alongside the app-specific files.

What worked, what didn’t

TOML as single source of truth worked well. Adding a brand is one line in the TOML, not a change across three different files. All scripts, including CI pipelines, read from there.

copyfiles is sufficient for this use case. No need for rsync or more complex sync tools — Shopify theme directory structure is flat and predictable.

The update-repo bash script is fragile on partial errors. If theme:pull fails on one store in the middle of the loop, the cycle continues and the final push to main includes the successful pulls. A more robust approach would record failures and skip the push until they’re resolved.

Brand overrides work but don’t scale cleanly. With many diverging snippets between brands, the diff between packages/theme/ and a brand’s app directory becomes hard to track. We managed this with discipline: significant variations become parameters of the shared component, not separate copies.