Parallel Development in Optimizely CMS SaaS: A Smarter Way to Push Content Models
When Optimizely shipped the JavaScript SDK and CLI for CMS SaaS, they gave developers something pretty cool — a code-first workflow for content modeling. Define your content types in .tsx files, run npx @optimizely/cms-cli@latest push, and your content models land in the CMS instance via the REST API.
Clean. Fast. Developer-friendly.
But here's the thing about developer tools — the moment you hand them to a team, the dynamics shift. And that's not a flaw in the tooling. It's just what happens when multiple people start pushing content models to the same environment at the same time.
Let's talk about that.
The Reality of Parallel Development
Picture this: you're on a feature branch, building out a HeroBanner component. Your teammate is on a separate branch, refining the ArticlePage schema. Both of you are pointing your CLI at the same non-production CMS instance.
You run push. The CLI reads your optimizely.config.mjs, scans the entire src/components/ directory, and attempts to sync everything it finds — your changes, your teammate's changes, and every other content type definition sitting in the codebase.
Now, if your local branch doesn't have your teammate's latest work (because they haven't merged yet), the CLI sees a mismatch. It flags a conflict. You get an error.
This is actually good behavior. The CLI is protecting you from accidentally overwriting someone else's work. Optimizely built in a --force flag for exactly this reason — when you know what you're doing and want to push regardless, you can.
But here's where the mental model needs to shift.
The Force Flag Solves the Wrong Problem (For This Scenario)
Let's be clear — the --force option is a legitimate and useful tool. It exists because there are real scenarios where you need to override what's on the remote instance. Schema migrations, breaking changes, cleanup operations — force is the right call.
But in a parallel development workflow, the question isn't "how do I push harder?" — it's "why am I pushing things I didn't change?"
Think about it. If you only modified HeroBanner.tsx, why should the CLI even look at ArticlePage.tsx? Why should it care about 47 other component files that you never touched?
The problem isn't conflict resolution. The problem is scope.
A Different Way to Think About It
Here's the mental shift: instead of treating the CLI push as a full sync operation, treat it as a deployment of your changes.
This is how we already think about code deployments. Your CI/CD pipeline doesn't redeploy every microservice when you change one. Your database migration tool doesn't re-run every migration from the beginning. You deploy what changed.
Content model pushes should work the same way.
The good news? The CLI already supports this. The optimizely.config.mjs file controls which components the CLI processes. If you narrow the components array to only the files you've changed, the CLI will only push those content types. No conflicts with your teammate's work. No force flags needed. No wasted API calls.
The trick is doing this dynamically — based on your actual Git branch changes.
Git as Your Scope Engine
Your version control system already knows exactly what you changed. A simple git diff against your base branch gives you the precise list of modified .tsx files. From there, it's straightforward to generate a temporary config file, push only those components, and clean up.
Here's a Node.js script that handles the entire flow:
// push-branch-changes.mjs
import { execSync } from 'child_process';
import fs from 'fs';
import path from 'path';
const baseBranch = process.argv[2] || 'origin/main';
const tempConfig = 'optimizely.config.temp.mjs';
try {
console.log(`Checking changes against ${baseBranch}...`);
const gitOutput = execSync(
`git diff ${baseBranch} --name-only -- "src/components/**/*.tsx"`,
{ encoding: 'utf8' }
);
const changedFiles = gitOutput
.split('\n')
.map(f => f.trim())
.filter(f => f.length > 0 && fs.existsSync(f));
if (changedFiles.length === 0) {
console.log('No component changes on this branch. Nothing to push.');
process.exit(0);
}
console.log(`\nPushing ${changedFiles.length} changed component(s):`);
changedFiles.forEach(f => console.log(` - ${f}`));
// Generate a temporary config scoped to changed files only
const paths = changedFiles.map(f => `'./${f}'`).join(',\n ');
const config = `import { buildConfig } from '@optimizely/cms-sdk';
export default buildConfig({
components: [
${paths}
],
});
`;
fs.writeFileSync(path.resolve(tempConfig), config, 'utf8');
// Push using the scoped config
execSync(`npx @optimizely/cms-cli@latest push --config ${tempConfig}`, {
stdio: 'inherit',
});
// Cleanup
fs.unlinkSync(tempConfig);
console.log('Done. Only your branch changes were pushed.');
} catch (err) {
console.error('Push failed:', err.message);
if (fs.existsSync(tempConfig)) fs.unlinkSync(tempConfig);
process.exit(1);
}
Wire it up in your package.json:
"scripts": {
"cms:push-branch": "node push-branch-changes.mjs"
}
And run it:
# Compare against origin/main (default)
npm run cms:push-branch
# Or compare against a different base
npm run cms:push-branch origin/develop
That's it. The script diffs your branch, generates a scoped config, pushes only your changes, and cleans up after itself.
What This Actually Gets You
Speed. Instead of the CLI parsing and validating every content type in your project, it processes only the handful you touched. On larger codebases with dozens of content types, the difference is noticeable.
Safety. You literally cannot overwrite a teammate's content type because the CLI never sees it. There's no conflict to resolve, no force flag to debate, no Slack message asking "did someone just push over my changes?"
Focus. Your push operation maps 1:1 to your pull request. What you changed in code is what gets pushed to the CMS. Nothing more, nothing less. That's easier to review, easier to debug, and easier to roll back.
Where This Fits in a Bigger Picture
This approach pairs well with a structured environment strategy. Consider this setup:
|
Environment |
Who Pushes |
How |
|
Developer instances |
Individual devs |
Branch-scoped push (this script) |
|
Shared QA/Staging |
CI/CD pipeline |
Full push on merge to main |
|
Production |
CI/CD pipeline |
Full push on release |
Developers get fast, isolated feedback loops on their own instances. The shared environments only receive content models that have been reviewed and merged. Production stays locked down behind your release process.
The Optimizely CLI's full push behavior is exactly right for CI/CD — you want a complete sync when deploying from a merged, canonical branch. The branch-scoped approach fills the gap for the development phase, where speed and isolation matter more than completeness.
Wrapping Up
The Optimizely CMS SaaS CLI is a solid tool. The force option exists for a reason and handles real conflicts well. But parallel development introduces a different kind of challenge — not "how do I resolve conflicts" but "how do I avoid creating them in the first place?"
By scoping your pushes to only the content types you've actually changed, you sidestep the problem entirely. Your Git branch becomes the source of truth for what gets pushed, and everyone on the team stays out of each other's way.
It's a small script with a big impact on your daily workflow. Give it a try on your next feature branch.
Have you tried a similar approach or found other ways to streamline parallel development with Optimizely CMS SaaS? We'd love to hear what's working for your team.
Comments