Building a Project Scaffolding CLI is more than copying templates

A few months ago, I wanted to solve a problem I had faced more times than I wanted to admit. Whenever I started a new Express.js project, I would go through the same setup again: TypeScript, Prisma, authentication, validation, folder structure, linting, testing, maybe Swagger, maybe Docker, and a few other things depending on the project.
None of these steps were particularly difficult. The annoying part was doing them again and again.
At the same time, I was using tools like Vite and Next.js, where you can run one command, answer a few questions, and get a project tailored to your needs. That gave me an idea:
What if I built something similar for Express?
So I built create-express-preset, a CLI that could scaffold an Express + Prisma application and optionally add features such as Swagger, Jest, Docker and a password-reset flow using Resend. The CLI uses a base template and then applies selected addons to it.
At the time, my goal was mostly to learn how such developer tools are built. Looking back at the project now, I also realized something more interesting:
A scaffolding CLI starts simple, but the moment you want it to be flexible, you are no longer just copying templates. You are building a small project composition system.
Birth of a very simple idea
The first version of the mental model is almost too easy in the rough paper:
CLI → Ask user what they want → Copy template → Done
And honestly, that is enough for a basic scaffolder. You can keep a template somewhere, copy it into a new directory, change the project name, and call it a day. The problem starts when the CLI becomes something like:
- Do you want Swagger?
- Do you want tests?
- Do you want Docker?
- Do you want authentication?
- Which database?
- Which package manager?
- Do you want Git?
Now the generated project is no longer one fixed template, but a combination of several choices. That changes the problem completely.
My own project followed this same path. I had a base Express + Prisma template, and selected addons were copied into the generated project and then used to modify files, dependencies, configuration and, in some cases, the Prisma schema.
At first, this feels like a bigger version of copying files. It isn't.
The CLI is only the tip of the iceberg
One thing I would think about earlier today is separating the CLI from the actual generation engine. The CLI's job should mainly be to understand the user:
User → CLI prompts / arguments → Project configuration
After that, another part of the system should take over:
Project configuration
↓
Generator
├── resolve template
├── apply addons
├── merge dependencies
├── modify configuration
├── transform source code
└── validate outputThis separation matters because the CLI should not need to know the implementation details of every feature. For example, I would rather have the CLI understand:
"the user selected Swagger"
than:
"the Swagger option means copy these files, add these dependencies, inject this import, add this route and modify these environment variables."
Once those details start leaking into the CLI itself, every new feature becomes a reason to touch the core generator. That is one of the limitations I can see clearly in my own project now. The CLI has explicit options for the addons I originally supported, and createProject() maps those options to addon names.
That worked perfectly well for four addons. It just would not be the architecture I would choose if I expected dozens of addons.
An addon should be more than a folder
This was probably the biggest thing I underestimated. At first, an addon sounds like:
swagger/ files/
But a real addon can affect many parts of a project.
It might need to:
- add dependencies
- add new files
- modify existing source files
- add environment variables
- add scripts
- extend a database schema
- depend on another addon
- conflict with another addon
- run some setup step after generation
My own addons already needed several of these things. The repository defines addon files, package extensions, configuration for imports/routes, and Prisma schema extensions.
So, I think a better mental model is:
An addon is a description of changes it wants to make to the generated project.
// Instead of hardcoding:
swagger: boolean
tests: boolean
docker: boolean
// I would want something closer to:
type Addon = {
name: string;
dependencies?: ...;
files?: ...;
transformations?: ...;
requirements?: ...;
conflicts?: ...;
};The exact implementation can vary, but the important part is that the core generator should know how to process an addon without knowing what the addon actually is. That is what would make the system easier to extend.
Merging is the hardest part
Copying a new file is easy. Suppose an addon gives you:
docker/
Dockerfile
docker-compose.yml
Just copy them.
But what happens when an addon wants to change an existing file? Imagine the base project has:
const app = express();and three different addons want to register middleware:
- Logger
- Rate Limiter
- Swagger
Now you need a strategy for modifying the same file without destroying existing code.
My first implementation handled this with a mixture of file copying, JSON merging, markers and text-based source manipulation. The merge utility looks for predefined sections and inserts content between them. For a small project, this is a perfectly reasonable solution. But it also taught me where the limits are.
- If the generated file changes, the marker might disappear.
- If the source gets formatted differently, a regex may stop matching.
- If two addons want to modify the same part of a file, ordering suddenly matters.
- And if the user changes the generated file later, the assumptions made by the generator are no longer guaranteed to be true.
This is why I would separate merging into different categories.
File-level changes
Use normal copying when an addon simply adds a new file.
Structured configuration
For things such as package.json, parse the JSON and merge the actual structure instead of manipulating it as text.
Source-code changes
This is where things get harder.
A small generator can use explicit extension points or carefully controlled transformations. A larger generator may need AST-based transformations, where the tool understands that it is modifying an import, function, route or middleware registration instead of blindly searching for a string.
I don't think every scaffolding tool needs an AST system. The formula is simple, The more freedome you give addons, the less reliable plain string replacement becomes. If you want to support a few addons, explicit extension points seem fine. If you want to support dozens of addons, AST-based transformations are probably the only (or, better) way to go.
Special cases
Database schema changes
Prisma was one of the places where my original assumptions became particularly visible. I wanted addons to be able to extend the Prisma schema, so I built a small merger that reads models from extension files and combines them with the project's schema. It worked for the schema structures I expected.
But a database schema is not just a collection of lines containing field names. As soon as you support more complicated models, relations, indexes, attributes and other schema features, a regex-based approach becomes increasingly difficult to trust.
The same idea applies to TypeScript, ESLint configs, Prisma schemas and even package metadata.
Addon ordering and conflicts
With a few addons, processing them one after another is simple. But imagine the tool eventually has twenty. Now you might have relationships like:
- Addon A requires Addon B
- Addon C conflicts with Addon D
- Addon E should run after Addon F
And two addons may both modify the same resource. At that point, this:
for (const addon of addons) {
process(addon);
}may no longer be enough.
A more scalable system would probably give addons some way to describe their requirements, conflicts and ordering constraints. You may even end up resolving a dependency graph before generation starts.
I would not build that complexity into a four-addon project. But I would keep it in mind if the goal is to create a general-purpose scaffolding platform.
Package managers and Operating systems
When a CLI runs on my machine, I know my environment. When I publish it to npm, I don't.
The user may be running:
npm / pnpm / yarn / bun
on:
Windows / macOS / Linux
with a different Node.js version and a completely different shell environment.
My project already has package-manager detection and shell-command helpers, but these are the kinds of areas where a small personal tool can quickly become surprisingly platform-dependent.
Avoid shell-specific assumptions where possible, use Node APIs for filesystem work, handle process failures properly, and test the paths that are meant to work across platforms.
Maintenance is harder than the first release
Imagine your scaffolder becomes popular and you support addons for things like:
Prisma, Swagger, Redis, React, Auth0, Resend, Sentry, Docker
Now those technologies keep moving.
- A new major Prisma release may change how something should be configured.
- A new Swagger package may change its setup.
- An authentication library may deprecate an API.
Your scaffold tool has effectively become a collection of integrations that need to stay healthy over time. So there are now two kinds of maintenance:
Maintain the generator + Maintain the integrations it generates
And those integrations can have their own version compatibility problems.
This is one reason I would think about versioning addons independently, defining compatibility requirements where necessary, and keeping the core generator as unaware of individual technologies as possible.
The bigger your addon ecosystem becomes, the more important this gets.
What I would change if I built it again
The interesting part of this project is that I don't think my original implementation was useless. It did exactly what I wanted at the time. I designed around four or five addons, built a merge system around those assumptions, and got a working CLI out of it.
The problem is that the architecture was shaped by the current features instead of the general problem. If I were starting again, I would probably think about the system more like this:
The important difference is that the generator would not be built around Swagger, Docker or Resend.
It would be built around what kinds of changes an addon is allowed to make. That makes the system easier to extend without turning the core CLI into a giant list of special cases. And I would be much more deliberate about testing generated applications, handling addon dependencies and conflicts, and keeping source transformations structured where possible.
The biggest lesson
When I started this project, I thought the interesting part was building the CLI.
It wasn't.
The CLI was actually the easy part. The difficult part was answering:
How do I reliably combine many independent decisions into one valid project?
You are dealing with files, source code, dependencies, configuration, databases, package managers, operating systems, version compatibility, failure handling and testing ~ all while trying to make the final result feel like a normal project that a developer could have created manually.
That's why I now think a project scaffolding CLI is more than a template copier. It is a small system for composing and generating software.
My first version was built to solve my immediate problem and, more importantly, to teach me how this kind of tooling works. Looking back at it with more experience, I can see where I made assumptions that would not hold at a larger scale. And honestly, that's probably the most useful thing I got from the project.
I didn't build the perfect create-express tool. I built one that worked, found the edges of my own design, and learned what I would think about before scratching the next one.
Thank you for reading! Hope you found this useful. If you want to check out the project that I built based on initial ideas, it is open-source and available on GitHub.