Shipping next-cli, and the bugs hiding in a 'working' scaffolder
I set out to finish next-cli, my one-command Next.js scaffolder. It looked done — but almost nothing it generated actually built. Here's the tool, the bugs, and what I took away.
What next-cli is
next-cli is an interactive command-line tool that scaffolds a production-ready Next.js app in one command. You run it, answer a few prompts — database, ORM, auth, API layer, payments, email, analytics — and it generates a typed, configured project you can run immediately.
npx next-cli@latest
Think of it as a personal, opinionated take on create-t3-app: pick your stack, and the right files, dependencies, and environment variables get wired together for you.
It supports a fairly wide menu:
- Auth — NextAuth, Clerk, Kinde
- Database — PostgreSQL, MySQL, SQLite, MongoDB
- ORM — Prisma, Drizzle
- API — tRPC, GraphQL, Hono, REST
- Payments — Stripe, Paypal, Lemon Squeezy, Razorpay
- Email — Resend, SendGrid, Mailgun, Postmark
- Analytics — Vercel, Google
That list is the happy story. The reality, when I sat down to "finish" it, was messier — and more interesting.
The state I found it in
On paper the CLI was mostly built. The prompts were all there, the architecture was clean (a registry of installers, each copying a template and adding dependencies), and it compiled. So I did the thing you should always do before trusting a generator: I actually generated a project and tried to build it.
It didn't build. None of the combinations did. And the reasons turned out to be a great tour of how software can look finished while being quietly broken.
The bug that set the tone
The very first thing I checked was the "No Database" path. Here's roughly what init looked like:
if (options.database !== "none") {
// ...assemble packages...
await createProject({ ...options, packages });
}
removeTsNoCheck(options.projectDir);
Spot it? The entire project only got created when you picked a database. Choose "No Database" and the CLI cheerfully ran through every prompt, then generated nothing — and immediately tried to post-process a directory that didn't exist. Moving createProject out of that branch was a one-line fix, but it told me everything about how much had actually been run end-to-end.
It got better. A bunch of prompts were pure decoration — you could select Drizzle, GraphQL, Stripe, or NextAuth (the default auth option!) and the CLI would happily skip them, because nothing mapped those choices into the installer set. And one installer was registered like this:
resend: {
inUse: packages.includes("resend"),
installer: () => resendInstaller, // returns the function instead of running it
}
So even when you picked Resend, the installer never actually executed.
The @ts-nocheck trap
This is the one I'll remember. Every template file started with // @ts-nocheck, which made me assume generated code was intentionally exempt from type checking. But there was a post-processing step:
// strips "// @ts-nocheck" from every generated file
removeTsNoCheck(projectDir);
So the generated project was fully type-checked by next build — under strict and noUncheckedIndexedAccess. The @ts-nocheck comments were a false sense of safety; they were deleted before the user ever ran a build. That meant a pile of latent type errors in templates that had never been compiled:
const i = items.findIndex((x) => x.id === id);
items[i].name = name; // Object is possibly 'undefined'
The lesson landed hard: if you strip the escape hatch, you own the type errors.
Death by a thousand missing dependencies
Several templates imported packages the installers never added:
- the generated
env/index.tsimporteddotenv-expand— not installed - the
cn()helper importedclsxandtailwind-merge— not installed - the tRPC templates imported
superjson— not installed
Each one was an instant "Module not found" on first build. Individually trivial; collectively, the difference between a tool that works and one that doesn't.
The swapped arguments
My favourite bug. The Hono installer:
fs.writeFileSync(honoClientSrc, honoClientDest);
The arguments are backwards. Instead of copying the client template into the project, it wrote the destination path string into the template file itself — corrupting the package's own source — and never created the client in the user's app. copySync(src, dest) is what it wanted.
The alias that only worked half the time
The CLI offers a src/ directory or a root app/ layout. But the generated tsconfig.json hardcoded:
"paths": { "@/*": ["./src/*"] }
Pick the no-src layout and every @/... import broke, because they all resolved into a src/ folder that didn't exist. The env bootstrap had the same hardcoded assumption. Both needed to become layout-aware.
How I actually verified it
The thing that surfaced all of this was a simple discipline: don't trust that the CLI builds — build what it produces. I scaffolded three very different projects and ran a real next build on each:
- a
src/app with Prisma + tRPC + NextAuth + Stripe - a no-
srcapp with Drizzle + GraphQL + SendGrid - a database-less app with Kinde + REST + Razorpay
Every failure above came from one of those builds, not from the CLI's own test suite (which was green the whole time). A generator's tests are worthless if they don't compile the output.
What I took away
- Generators must build their output in CI. Unit-testing the generator proves nothing about the project it emits.
- Don't suppress type checking you intend to keep.
@ts-nocheckplus a step that removes it is the worst of both worlds. - Pin and test dependency lists. A template that imports a package the installer forgot is a guaranteed first-run failure.
- Every prompt is a promise. If you ask the user to choose, the choice has to do something.
- "It compiles" and "it works" are different claims. The CLI compiled from day one. It just didn't work.
next-cli now scaffolds and builds across every combination I throw at it. If you want to try it:
npx next-cli@latest
Source and issues live on GitHub. If you find a combination that doesn't build — now you know exactly the kind of bug I'd want to hear about.