Config-as-code for Shopify metafields and metaobjects. Inspired roughly by Terraform and platforms like Vercel.
Sync definitions across environments with GitHub. Push from your store, promote via Pull Requests, pull into any store, with a full diff before you apply.
MetaForm is built for teams running dev → UAT → production who need metafield and metaobject definitions to stay in sync without manual copy-paste or drift.
MetaForm runs as a Shopify app with a backend API, GitHub OAuth, and optional deployment on DigitalOcean App Platform. Definitions live in your repo; the app pushes and pulls them from the store.
Settings: GitHub OAuth credentials, repository, branch, file path, and optional sync source.
Definitions: Browse all metafield and metaobject definitions on the store.
Sync Data: Review changes from source (added, modified, removed) and create a branch + PR to promote.
Sync Data (changes): Detailed view of added metafields and metaobjects before applying.
- Architecture
- Screenshots
- Quick Start
- What MetaForm Does
- How It Works
- GitHub OAuth App Setup
- App Configuration
- Pages and Features
- Project Structure
- Deployment
- Tech Stack
- Troubleshooting
- Node.js 20.10 or higher
- Shopify CLI installed globally
- A Shopify Partner account with a development store
- A GitHub account
git clone https://github.com/your-org/shopify-metaform.git
cd shopify-metaform
npm installshopify app config linkThis connects the project to your Shopify Partner app. If you don't have one yet, the CLI will walk you through creating one.
npx prisma migrate deployshopify app devPress p to open the app in your browser. Install it on your development store when prompted.
- Create a GitHub OAuth App -- set the callback URL shown on the Settings page
- Open MetaForm Settings in the Shopify admin
- Enter your GitHub OAuth App Client ID and Client Secret, click Save credentials
- Click Connect to GitHub -- a popup opens for authorization
- After authorizing, select your repository and branch
- Click Save settings
You're ready to push and pull definitions.
MetaForm manages metafield definitions and metaobject definitions -- the schemas that define custom data structures on your Shopify store. It does not manage the values or entries stored in those fields (that's a future feature).
When you have multiple Shopify stores (dev, staging, production), there's no built-in way to keep metafield and metaobject definitions in sync. Developers end up manually recreating definitions in each environment, leading to:
- Definition drift between environments
- Broken deployments when a field is missing in production
- No audit trail of what changed and when
- No review process for schema changes
MetaForm treats definitions as config-as-code:
- Push your store's definitions to a GitHub branch as a JSON file
- Sync definitions from a source environment into your store
- Promote definitions between environments using GitHub Pull Requests
- Diff before applying -- see exactly what will be created, updated, or removed
Each Shopify store maps to a branch in a GitHub repository:
Dev Store --> dev branch
UAT Store --> uat branch
Prod Store --> main branch
- A developer creates or modifies metafield definitions on the dev store
- They open MetaForm and click Push -- the definitions are committed to the
devbranch - They go to Settings and click Open PR on GitHub targeting the
uatbranch - The team reviews the PR on GitHub (GitHub shows the JSON diff natively)
- The PR is merged into
uat - On the UAT store, the developer opens MetaForm and clicks Pull -- a diff preview shows what will change
- They click Apply to sync the definitions
- Repeat the PR workflow from
uattomainfor production
Store Definitions --> MetaForm captures snapshot --> Diffs against branch file --> Commits to GitHub
- MetaForm queries all metafield definitions (across all owner types) and metaobject definitions via the Shopify Admin GraphQL API
- It serializes them into a canonical JSON format (
definitions.json) - It reads the current file from the GitHub branch and computes a diff
- If there are changes, it commits the new file with a descriptive commit message
GitHub branch file --> MetaForm reads + parses --> Diffs against store --> User confirms --> Mutations applied
- MetaForm reads
definitions.jsonfrom the connected branch - It queries the current store's definitions
- It computes a diff: what needs to be created, updated, or deleted
- The user reviews the diff and confirms
- MetaForm executes the GraphQL mutations (
metafieldDefinitionCreate,metaobjectDefinitionCreate, etc.)
MetaForm connects to GitHub using an OAuth App that you create on your own GitHub account.
- Go to GitHub Developer Settings > OAuth Apps
- Click New OAuth App
- Fill in:
- Application name:
MetaForm(or whatever you like) - Homepage URL: Your app's URL
- Authorization callback URL: Copy this from the MetaForm Settings page (it's shown at the top of the credentials section). The format is
https://<your-app-url>/auth/github/callback
- Application name:
- Click Register application
- Copy the Client ID
- Click Generate a new client secret and copy the Client Secret
During development: The callback URL changes when
shopify app devrestarts the tunnel. Update the callback URL in your GitHub OAuth App settings after each restart. The current URL is always displayed on the MetaForm Settings page.
- Open MetaForm in your Shopify admin
- Go to Settings
- Note the Authorization callback URL shown at the top -- make sure it matches your GitHub OAuth App
- Enter your Client ID and Client Secret
- Click Save credentials
- Click Connect to GitHub (appears after saving credentials)
- A popup window opens with GitHub's authorization page
- Click Authorize on GitHub
- The popup shows "Connected!" and closes automatically
- The Settings page refreshes to show your GitHub username and repository options
The credentials and access tokens are stored securely in the app's database with AES-256-GCM encryption. Nothing is stored in environment variables.
All configuration is stored in the app's SQLite database. There are no environment variables to set beyond the standard ones provided by the Shopify CLI.
| Setting | Where it's stored | How it's set |
|---|---|---|
| GitHub Client ID | AppConfig table |
Settings page |
| GitHub Client Secret | AppConfig table (encrypted) |
Settings page |
| Encryption key | AppConfig table |
Auto-generated on first use |
| GitHub access token | GitHubConnection table (encrypted) |
OAuth flow |
| Repository / branch | GitHubConnection table |
Settings page |
Overview page showing:
- Count of metafield definitions and metaobject definitions on the store
- GitHub connection status and sync state
- Quick action buttons (Push, Pull, View Definitions)
- Recent sync activity log
Read-only browser of all definitions currently on the store:
- Metafields tab: All metafield definitions, filterable by owner type (Product, Customer, Order, Collection, etc.)
- Metaobjects tab: All metaobject definitions with field counts and capabilities
Push your store's current definitions to GitHub:
- Captures a snapshot of all definitions
- Reads the current file from the branch
- Shows a diff preview (added, modified, removed)
- Lets you edit the commit message
- Commits to the branch on confirm
Sync definitions from a source environment into your store:
- Reads the definitions file from the configured sync source (lower environment)
- Captures the store's current state
- Shows a diff preview of added and modified definitions
- Applies changes in the correct order (metaobjects first, then metafields) to handle dependencies
- Shows a detailed step-by-step result log with helpful error messages
Three sections:
- GitHub App Credentials: Enter your GitHub OAuth App Client ID and Secret
- GitHub Connection: Connect/disconnect GitHub, select repository, branch, file path, and toggle auto-import
- Create Pull Request: Select a target branch and open a PR on GitHub to promote definitions
All server-side logic lives in app/services/ as .server.ts files (never shipped to the client):
| Service | File | Purpose |
|---|---|---|
| App Config | app-config.server.ts |
Manages GitHub credentials and encryption key in the database |
| Encryption | encryption.server.ts |
AES-256-GCM encrypt/decrypt for tokens at rest |
| Metafield Definitions | metafield-definitions.server.ts |
CRUD operations via Shopify Admin GraphQL |
| Metaobject Definitions | metaobject-definitions.server.ts |
CRUD operations via Shopify Admin GraphQL |
| Snapshot | snapshot.server.ts |
Capture all definitions, diff two snapshots, generate commit messages |
| GitHub | github.server.ts |
OAuth flow, repo/branch listing, file read/write via Octokit |
Defined in prisma/schema.prisma using SQLite:
| Model | Purpose |
|---|---|
Session |
Shopify session storage (standard) |
AppConfig |
Singleton storing encryption key and GitHub OAuth credentials |
GitHubConnection |
Per-shop GitHub connection: token, repo, branch, file path, sync state |
SyncLog |
Audit trail of push/pull operations |
The definitions.json file committed to GitHub follows this schema:
{
"version": "1.0",
"capturedAt": "2025-01-15T10:30:00.000Z",
"shop": "my-store.myshopify.com",
"metafieldDefinitions": [
{
"ownerType": "PRODUCT",
"namespace": "custom",
"key": "warranty_info",
"name": "Warranty Info",
"description": "Product warranty details",
"type": "single_line_text_field",
"validations": [],
"access": { "admin": "MERCHANT_READ_WRITE", "storefront": "NONE" }
}
],
"metaobjectDefinitions": [
{
"type": "size_chart",
"name": "Size Chart",
"description": null,
"displayNameKey": "size",
"access": { "admin": "MERCHANT_READ_WRITE", "storefront": "PUBLIC_READ" },
"capabilities": { "publishable": true, "translatable": false, "renderable": false },
"fieldDefinitions": [
{
"key": "size",
"name": "Size",
"type": "single_line_text_field",
"description": null,
"required": true,
"validations": []
}
]
}
]
}Metafield definitions are identified by {ownerType}:{namespace}:{key}. Metaobject definitions are identified by {type}. These keys are used for diffing.
shopify-metaform/
app/
routes/
app.tsx # Layout with sidebar navigation
app._index.tsx # Dashboard
app.definitions.tsx # Definitions browser
app.push.tsx # Push to GitHub
app.sync.tsx # Sync data from source environment
app.settings.tsx # Settings + GitHub connection
services/
app-config.server.ts # App config (credentials, encryption key)
encryption.server.ts # AES-256-GCM encryption
github.server.ts # GitHub API via Octokit
metafield-definitions.server.ts
metaobject-definitions.server.ts
snapshot.server.ts # Capture + diff definitions
types/
admin.ts # Shopify admin API context type
definitions.ts # Shared types for definitions and diffs
utils/
github-urls.ts # GitHub URL builders (compare, PR)
db.server.ts # Prisma client
shopify.server.ts # Shopify app configuration
root.tsx # HTML root
prisma/
schema.prisma # Database schema
shopify.app.toml # Shopify app configuration
package.json
npm run buildnpm run setup # Prisma generate + migrate deploy
npm run start # Start the production serverA Dockerfile is included for containerized deployment:
docker build -t metaform .
docker run -p 3000:3000 metaformDeploy via the GitHub + App Platform integration: connect this repo in DigitalOcean and every push to your branch triggers a new deploy. The repo includes .do/app.yaml so App Platform uses the correct build, run, and env config.
-
Connect GitHub to App Platform
In DigitalOcean → Create App → From GitHub → choose this repo and the branch to deploy (e.g.main). App Platform will pick up.do/app.yamland usedeploy_on_push: true, so pushes to that branch auto-deploy. -
Confirm or set the repo in the spec
If the repo wasn’t auto-filled, edit.do/app.yamland setgithub.repoto your repo (e.g.your-org/shopify-metaform). -
Set environment variables
In your app → Settings → App-Level Environment Variables, set every key from.env.example. EncryptDATABASE_URL,SHOPIFY_API_KEY, andSHOPIFY_API_SECRET. Use your existing Prisma PostgresDATABASE_URL; no DO database component is needed. -
After first deploy
Set your app URL (e.g.https://metaform-app-xxxxx.ondigitalocean.app) asSHOPIFY_APP_URLin App Platform and as App URL in your Shopify Partner Dashboard.
Each deploy runs prisma generate, prisma migrate deploy, and npm run build, then npm run start on port 8080.
If App Platform uses the Dockerfile (Build strategy: Dockerfile): leave Run command empty (the Dockerfile CMD runs migrations then the server). Set Public HTTP port to 3000. Add all env vars from .env.example at App-level; the container runs prisma migrate deploy at startup, so DATABASE_URL is required.
- Google Cloud Run
- Fly.io
- Render
- Any Node.js hosting that supports PostgreSQL
Set NODE_ENV=production in your hosting environment.
- Framework: React Router v7 (file-based routing)
- UI: Polaris Web Components
- API: Shopify Admin GraphQL API
- GitHub: Octokit
- Database: Prisma + PostgreSQL
- Language: TypeScript
- Build: Vite
Run the database setup:
npm run setupYour browser may be blocking the popup. Allow popups for the Shopify admin domain in your browser settings. The OAuth flow opens in a separate popup window to avoid iframe issues.
Make sure the Authorization callback URL in your GitHub OAuth App settings matches the URL shown on the MetaForm Settings page exactly. The format is https://<your-app-url>/auth/github/callback. During development, this URL changes when the tunnel restarts -- update it in GitHub after each restart.
Go to Settings in the app and enter your GitHub OAuth App Client ID and Client Secret. See GitHub OAuth App Setup.
The app queries definitions across all owner types (Product, Customer, Order, etc.). If you have no metafield or metaobject definitions on the store, the lists will be empty. Create some definitions in the Shopify admin first.
This means the definitions on your store match the file on the branch exactly. If you changed definitions outside of MetaForm (e.g. via the Shopify admin), try reloading the Push page to capture fresh data.
MIT




