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.
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:
- Runs
theme:pullfrom the development environment on Shopify - Runs
theme:check(Liquid linting) - On PR: deploys to a development theme and posts the preview link as a comment
- 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.