One Monorepo, Two Outputs: How I Eliminated Duplicate Starter Templates

작성자

카테고리:

← 피드로
DEV Community · Sanjay Kumar Sah · 2026-07-20 개발(SW)

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 Package packages/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.json manifests
  • Two TypeScript (tsconfig.json) configurations
  • Two distinct linting and formatting setups
  • Two shadcn/ui configuration 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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다