Project Structure: Organizing a React-Liquid Hybrid Theme
Learn how to organize your files for a React-powered Shopify theme. Understand the separation between source files and the deployable theme.
A well-organized project structure is the foundation of a maintainable theme. In this lesson, we’ll design a folder structure that cleanly separates your React source code from the deployable Shopify theme files.
The Challenge
Shopify themes have a specific structure that can’t be changed. React projects also have conventions. We need to merge these two worlds without creating chaos—and keep clear boundaries between what gets deployed and what doesn’t.
Shopify’s Required Structure:
theme/├── assets/ # CSS, JS, images├── config/ # settings_schema.json, settings_data.json├── layout/ # theme.liquid├── locales/ # translations├── sections/ # section files├── snippets/ # reusable liquid components└── templates/ # page templatesTypical React Project:
react-app/├── node_modules/├── src/│ ├── components/│ ├── hooks/│ └── main.tsx├── package.json└── vite.config.tsOur Hybrid Structure: Separation of Concerns
The key insight: keep the Shopify theme in its own folder. Vite outputs directly to shopify-theme/assets/, creating a clear boundary between source code and deployable files.
shopify-react-theme/│├── shopify-theme/ # DEPLOYABLE THEME (synced to Shopify)│ ├── assets/│ │ ├── react-bundle.js # Vite outputs directly here│ │ ├── react-bundle.css # Vite outputs directly here│ │ ├── base.css # Theme base styles (manual)│ │ └── ... other assets│ ├── config/│ │ ├── settings_schema.json│ │ └── settings_data.json│ ├── layout/│ │ └── theme.liquid│ ├── locales/│ │ └── en.default.json│ ├── sections/│ │ ├── header.liquid│ │ ├── footer.liquid│ │ ├── product-main.liquid│ │ └── ... other sections│ ├── snippets/│ │ └── ... liquid snippets│ └── templates/│ ├── index.json│ ├── product.json│ ├── collection.json│ └── ... other templates│├── src/ # REACT SOURCE (never deployed)│ ├── components/│ │ ├── cart/│ │ │ ├── CartDrawer.tsx│ │ │ ├── CartItem.tsx│ │ │ └── index.ts│ │ ├── product/│ │ │ ├── ProductForm.tsx│ │ │ ├── VariantSelector.tsx│ │ │ ├── MediaGallery.tsx│ │ │ └── index.ts│ │ ├── header/│ │ │ ├── Header.tsx│ │ │ ├── Navigation.tsx│ │ │ ├── MobileMenu.tsx│ │ │ └── index.ts│ │ ├── search/│ │ │ ├── SearchModal.tsx│ │ │ ├── SearchResults.tsx│ │ │ └── index.ts│ │ └── ui/│ │ ├── Button.tsx│ │ ├── Modal.tsx│ │ ├── Drawer.tsx│ │ └── index.ts│ ├── hooks/│ │ ├── useCart.ts│ │ ├── useProduct.ts│ │ ├── useMediaQuery.ts│ │ └── index.ts│ ├── stores/│ │ ├── cart.ts│ │ ├── ui.ts│ │ └── index.ts│ ├── types/│ │ ├── shopify.ts│ │ └── index.ts│ ├── utils/│ │ ├── data-bridge.ts│ │ ├── money.ts│ │ ├── api.ts│ │ └── index.ts│ ├── styles/│ │ ├── components/│ │ ├── variables.css│ │ └── main.css│ └── main.tsx # Entry point│├── .gitignore├── package.json├── tsconfig.json├── vite.config.ts├── shopify.theme.toml # Shopify CLI config└── README.mdWhy This Structure?
1. Clear Deployment Boundary
Everything inside shopify-theme/ gets deployed. Everything outside doesn’t. No confusion, no complex ignore lists.
# Deploy the themeshopify theme push --path shopify-theme2. Simple Build Pipeline
src/ → Vite builds → shopify-theme/assets/No intermediate folders. Vite outputs directly to where Shopify needs the files.
3. Clean Git Ignores
# Dependenciesnode_modules/
# React build output (regenerated)shopify-theme/assets/react-bundle.jsshopify-theme/assets/react-bundle.cssshopify-theme/assets/react-bundle.js.map
# Environment.env.env.local
# IDE.vscode/.idea/
# OS.DS_Store4. Easy CI/CD
Your CI pipeline knows exactly what to deploy:
# GitHub Actions example- name: Build React run: npm run build
- name: Deploy Theme run: shopify theme push --path shopify-themeKey Directories Explained
The shopify-theme/ Directory
This is your deployable theme. It contains only what Shopify needs:
shopify-theme/├── assets/ # Static files served by Shopify CDN├── config/ # Theme settings and schema├── layout/ # Base layouts (theme.liquid)├── locales/ # Translation files├── sections/ # Section files with schemas├── snippets/ # Reusable Liquid components└── templates/ # JSON templatesImportant: Vite outputs the React bundle directly here. You don’t need to copy anything.
The src/ Directory
This is where all your React code lives. It’s never deployed to Shopify.
src/├── components/ # React components, organized by feature├── hooks/ # Custom React hooks├── stores/ # Zustand stores for global state├── types/ # TypeScript interfaces├── utils/ # Helper functions├── styles/ # CSS files (bundled by Vite)└── main.tsx # Application entry pointComponent Organization
We organize components by feature, not by type. This keeps related code together:
# Good: Feature-based organizationsrc/components/├── cart/│ ├── CartDrawer.tsx # Main cart drawer component│ ├── CartItem.tsx # Single line item│ ├── CartTotals.tsx # Subtotal, taxes, total│ ├── CartEmpty.tsx # Empty state│ └── index.ts # Barrel export├── product/│ ├── ProductForm.tsx│ ├── VariantSelector.tsx│ └── ...# Avoid: Type-based organization (harder to navigate)src/components/├── buttons/│ ├── AddToCartButton.tsx│ ├── QuantityButton.tsx├── forms/│ ├── ProductForm.tsx├── modals/│ ├── CartDrawer.tsxBarrel Exports
Each feature folder has an index.ts that re-exports its components:
/* * Barrel Export Pattern * Re-exports all components from a single file, enabling cleaner imports: * import { CartDrawer, CartItem } from '@/components/cart' * Instead of: * import { CartDrawer } from '@/components/cart/CartDrawer' * import { CartItem } from '@/components/cart/CartItem' */export { CartDrawer } from './CartDrawer';export { CartItem } from './CartItem';export { CartTotals } from './CartTotals';export { CartEmpty } from './CartEmpty';This enables clean imports:
// Instead of multiple deep importsimport { CartDrawer } from './components/cart/CartDrawer';import { CartItem } from './components/cart/CartItem';
// Use a single importimport { CartDrawer, CartItem } from '@/components/cart';File Naming Conventions
Consistent naming makes your codebase predictable:
| File Type | Convention | Example |
|---|---|---|
| React Components | PascalCase | CartDrawer.tsx |
| Hooks | camelCase with use prefix | useCart.ts |
| Stores | camelCase | cart.ts |
| Utilities | camelCase | data-bridge.ts |
| Types | camelCase or PascalCase | shopify.ts |
| CSS Modules | camelCase | cartDrawer.module.css |
| Liquid Files | kebab-case | product-main.liquid |
The Entry Point
src/main.tsx is where React initializes and mounts components:
import { createRoot } from 'react-dom/client';import { CartDrawer } from '@/components/cart';import { ProductForm } from '@/components/product';import { Header } from '@/components/header';import { SearchModal } from '@/components/search';
import './styles/main.css';
/* * Component Registry Pattern * Maps DOM element IDs to React components. Liquid sections create the mount points * (e.g., <div id="cart-drawer-root"></div>), and React finds and mounts to them. * * Benefits: * - Liquid controls where components appear * - React only mounts where needed (not every page needs every component) * - Easy to add new components: just add to this object */const COMPONENTS: Record<string, React.ComponentType> = { 'cart-drawer-root': CartDrawer, // Mounts to header or layout 'product-form-root': ProductForm, // Only exists on product pages 'header-root': Header, // Always present 'search-modal-root': SearchModal, // Portal for search overlay};
// Scan the DOM for mount points and render componentsfunction mountComponents() { Object.entries(COMPONENTS).forEach(([elementId, Component]) => { const element = document.getElementById(elementId); if (element) { // Each component gets its own React root (islands architecture) createRoot(element).render(<Component />); } // If element doesn't exist on this page, component simply doesn't mount });}
// Handle both script loading scenariosif (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', mountComponents);} else { mountComponents();}Liquid Mount Points
Liquid sections define where React components mount:
{%- comment -%} shopify-theme/sections/product-main.liquid {%- endcomment -%}
<section class="product-main"> {%- comment -%} SEO content rendered by Liquid {%- endcomment -%} <h1>{{ product.title }}</h1>
{%- comment -%} React mount point {%- endcomment -%} <div id="product-form-root"></div>
{%- comment -%} Data for React {%- endcomment -%} <script type="application/json" id="product-data"> {{ product | json }} </script></section>
{% schema %}{ "name": "Product Main", "settings": []}{% endschema %}TypeScript Path Aliases
Configure path aliases for cleaner imports:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"], "@components/*": ["src/components/*"], "@hooks/*": ["src/hooks/*"], "@stores/*": ["src/stores/*"], "@utils/*": ["src/utils/*"], "@types/*": ["src/types/*"] } }}// Now you can import like this:import { CartDrawer } from '@/components/cart';import { useCart } from '@/hooks/useCart';import { formatMoney } from '@/utils/money';Development Workflow
With this structure, your workflow is:
- Edit React code in
src/ - Vite watches and builds directly to
shopify-theme/assets/ - Shopify CLI syncs
shopify-theme/to your dev store - Browser refreshes with the updated bundle
# Terminal 1 & 2 combined with concurrently:npm start
# This runs:# - Vite watching src/ → outputs to shopify-theme/assets/# - Shopify CLI watching shopify-theme/ → syncs to dev storeKey Takeaways
shopify-theme/is the deployment boundary—only this folder syncs to Shopifysrc/contains React source—never deployed, organized by feature- Vite outputs directly to
shopify-theme/assets/—no intermediate folders - Clear separation makes CI/CD, git, and collaboration simpler
- The build pipeline:
src/→shopify-theme/assets/
In the next lesson, we’ll set up Vite to output directly to your theme’s assets folder.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...