Part 2 of the Building create-notils series.
In my previous article, I explained why I stopped copy-pasting repositories and started building my own project scaffolding tool. However, one major architectural problem remained: I wanted create-notils to support both of these primary project structures.
A standalone Next.js application:
my-app/
├── src/
├── public/
├── package.json
└── components.json
Enter fullscreen mode Exit fullscreen mode
And a Turborepo monorepo:
my-app/
├── apps/
│ └── app/
├── packages/
│ ├── ui/
│ └── config/
├── turbo.json
└── package.json
Enter fullscreen mode Exit fullscreen mode
At first glance, the obvious solution is to maintain two separate templates—one for standalone and one for monorepo. Problem solved, right?
Except… it isn’t.
The Hidden Cost of Multiple Templates
Every starter template starts out identical. Then one day, you fix a subtle bug in one template and forget to update the other. A week later, you upgrade Next.js in one repository before getting around to the second. A month later, you improve your UI package and find yourself manually copying files back and forth between folders.
Eventually, the templates slowly drift apart. The true cost isn’t creating templates; the cost is maintaining them forever.
What Actually Changes?
When I sat down and compared the two project layouts side by side, surprisingly little was different. The actual application code, UI components, theming, and utility functions were 100% identical.
The only real differences were the structural project boundaries:
Concern Monorepo Standalone UI Packagepackages/ui
src/components/ui
Utilities
@notils/ui/lib/utils
@/lib/utils
Configuration
Shared workspace package
Local configuration
Package Manifests
Multiple (package.json files)
Single root manifest
Workspace Tooling
Present (turbo.json, workspaces)
Removed entirely
Everything else was effectively the exact same code. That single observation changed the entire architecture of create-notils.
A Different Approach: The Canonical Source of Truth
Instead of maintaining two templates, I decided to maintain only one. The monorepo became the canonical source of truth.
Whenever a developer chooses the monorepo option, the CLI simply clones and scaffolds it directly:
Canonical Monorepo
│
▼
Generated Monorepo
Enter fullscreen mode Exit fullscreen mode
When someone chooses a standalone project, something much more interesting happens. The CLI dynamically transforms the canonical monorepo into a flattened standalone application:
Canonical Monorepo
│
▼
Flatten Transform
│
▼
Generated Standalone
Enter fullscreen mode Exit fullscreen mode
Instead of storing two separate templates, I generate one directly from the other.
Thinking Like a Compiler
This shift changed how I thought about the CLI. It isn’t just copying static files anymore; it is compiling a project. The monorepo acts as the source code, and the generated starter is the compiled output.
Because of this, the transformation can be cleanly broken down into deterministic, sequential steps:
Canonical Project
│
▼
1. Resolve Workspace Packages
│
▼
2. Rewrite Import Paths
│
▼
3. Move & Flatten Files
│
▼
4. Merge package.json Manifests
│
▼
5. Inline Shared Configurations
│
▼
6. Output Standalone Project
Enter fullscreen mode Exit fullscreen mode
Each step in this pipeline has a single, isolated responsibility. That makes the entire process predictable, highly testable, and easy to evolve over time.
How the Monorepo Flattening Works
The transformation itself is surprisingly straightforward under the hood. Workspace packages simply become internal directories inside the application structure. For example, UI components are shifted directly into the source tree:
packages/ui/src/components/ui ──► src/components/ui
Enter fullscreen mode Exit fullscreen mode
Shared utility functions are moved alongside them:
packages/ui/src/lib/utils.ts ──► src/lib/utils.ts
Enter fullscreen mode Exit fullscreen mode
Next, the Abstract Syntax Tree (AST) or file contents are processed to rewrite import paths. A workspace import like this:
// Monorepo Workspace Import
import { Button } from "@notils/ui/components/ui/button";
Enter fullscreen mode Exit fullscreen mode
Is automatically rewritten to a standard Next.js path alias:
// Standalone Local Import
import { Button } from "@/components/ui/button";
Enter fullscreen mode Exit fullscreen mode
The exact same transformation happens for every shared workspace reference across the codebase. Zero component logic changes—only the artificial project boundaries disappear.
The Architectural Benefits
Treating the monorepo as a canonical compilation source provides several massive advantages for long-term maintenance:
1. One Source of Truth
Every bug fix, UI tweak, and architectural improvement is made exactly once. I never have to second-guess whether both project styles are synchronized.
2. Effortless Upgrades
When breaking changes drop for Next.js, Tailwind CSS, or React, I only update the canonical monorepo. Every generated project shape automatically inherits the upgraded foundation.
3. Automated CI Testing
Because generation is 100% deterministic, I can rigorously verify both outputs in a automated Continuous Integration (CI) pipeline:
Generate Monorepo ──► Build & Typecheck
│
▼
Generate Standalone ──► Build & Typecheck ──► Verify Zero "@notils/*" Imports Remain
Enter fullscreen mode Exit fullscreen mode
If the standalone transformation accidentally leaves behind even a single @notils/* workspace import, the CI pipeline fails immediately. Subtle scaffolding bugs become instant, actionable build failures.
Why Not Keep a “Shared Core” Submodule?
One common alternative I explored was keeping shared files in a common repository or submodule and wrapping two thin project templates around them. While this sounds clean in theory, it doesn’t actually eliminate maintenance duplication.
You still end up manually managing:
- Two separate
package.jsonmanifests - Two TypeScript (
tsconfig.json) configurations - Two distinct linting and formatting setups
- Two
shadcn/uiconfiguration files - Two separate build and deployment pipelines
The duplication doesn’t disappear; it just gets pushed to a different layer of the codebase. By treating the monorepo as the canonical compiler source, the standalone application becomes a zero-maintenance generated artifact.
Looking Ahead: From Templates to Targets
Right now, create-notils focuses exclusively on modern Next.js environments. But this compiler-style architecture opens up an exciting possibility: instead of thinking in terms of static templates, I’m starting to think in terms of compilation targets.
A single canonical project can eventually compile into completely different project shapes depending on the exact requirements of the developer. Today’s supported targets are simply monorepo and standalone. Tomorrow, they could expand to include mobile-first layouts, desktop wrappers, or alternate frameworks that share a unified design system and backend layer.
Conclusion
The most valuable lesson I learned while building create-notils wasn’t about Next.js app routers or Turborepo caching—it was about aggressively reducing maintenance overhead.
Every duplicated template is future technical debt. Every generated template is automation working in your favor.
When building developer tooling, the fundamental question stopped being:
“How many templates should I maintain?”
And became:
“How many project shapes can I generate from a single source of truth?”
That mindset shift fundamentally transformed how I build and maintain open-source software.
What do you think of generating standalone projects from a monorepo source of truth? Have you tackled template drift in your own tooling before? Let’s discuss in the comments!
Try out the compiler architecture yourself by running
npx create-notils my-app, or check out the source code on GitHub.
답글 남기기