How I Built and Published an npm CLI Tool
Every time I started a new full-stack project, I spent the first two hours doing the same thing.
Set up a monorepo. Wire up TypeScript. Configure ESLint and Prettier. Add a database client. Set up authentication. Configure Docker. Create shared packages. Write the CI pipeline.
Before writing a single line of actual product code.
After doing this three or four times, I decided to build a tool that does it for me. That tool became BuildElevate - an npm CLI that scaffolds a production-grade full-stack monorepo with a single command. Today it has 40+ GitHub stars, it's published on npm.
This is the full story of how I built it.
The Problem
Most starter templates are one of two things: too simple (a bare Next.js app with no structure) or too opinionated (a pre-wired SaaS boilerplate you have to surgically remove things from).
What I wanted was something in between - a tool that:
- Lets you choose what you need (fullstack / web-only / API-only)
- Scaffolds a properly structured monorepo with shared packages
- Sets up the boring-but-critical stuff: auth, rate limiting, emails, CI
- Feels like your codebase, not someone else's template you inherited
I wanted the npx create-next-app experience, but for an entire production monorepo.
Choosing the Stack
Before writing any code, I locked in the core technology choices. These decisions shaped everything downstream.
Turborepo for the monorepo
I evaluated Turborepo, Nx, and plain npm workspaces. Turborepo won for two reasons: the caching model is genuinely fast, and the config is minimal. Nx felt like it wanted to own my whole project. Plain workspaces lack the task pipeline system you actually need at scale.
apps/
web/ ← Next.js frontend
api/ ← Express backend
packages/
auth/ ← Better Auth config
database/ ← Prisma client + schema
email/ ← React Email templates
ui/ ← Shared component library
contracts/ ← Zod schemas shared across apps
rate-limit/ ← Upstash Ratelimit wrapper
*-config/ ← Shared ESLint, TypeScript configs
*-presets/ ← Shared Vitest presets
... (12 total)The key insight with monorepos: shared packages are what make the structure worth it. If your web app and api app share a Zod schema, you have one source of truth. Change the schema once, TypeScript errors surface everywhere it's misused instantly.
TypeScript end-to-end
Non-negotiable. The whole point of a shared packages architecture collapses without type safety across package boundaries.
Better Auth over NextAuth
I looked at NextAuth (now Auth.js), Clerk, and Better Auth. Clerk is excellent but it's a paid SaaS dependency - I didn't want to bake a third-party service into a starter. Auth.js is solid but the config is verbose and the database adapter story is messy.
Better Auth gave me clean TypeScript APIs, built-in support for email/password, Google OAuth, and TOTP 2FA, and a session management model I could actually understand.
export const auth = betterAuth({
database: prismaAdapter(prisma, { provider: "postgresql" }),
emailAndPassword: { enabled: true },
socialProviders: {
google: {
clientId: env.GOOGLE_CLIENT_ID,
clientSecret: env.GOOGLE_CLIENT_SECRET,
},
},
plugins: [twoFactor()],
});Upstash Redis for rate limiting
I needed rate limiting on auth routes specifically - brute-force login attempts are the most common attack vector on any app. Upstash's serverless Redis is free to start, has a great TypeScript SDK, and the sliding window algorithm is the right choice for auth rate limiting (it's stricter than fixed-window at the boundary).
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "10 s"),
analytics: true,
});
const { success } = await ratelimit.limit(identifier);
if (!success) throw new TooManyRequestsError();I also added fail-open behaviour - if the Redis connection is down, the request goes through rather than blocking all users. Rate limiting failing shouldn't take down your auth.
Building the CLI
The CLI itself was a project within the project. I wanted the experience to feel like create-next-app - interactive prompts, clear feedback, fast.
The library choices
@clack/promptsfor the interactive terminal UI (better thaninquirerfor modern terminals)
Interactive prompts
import { select } from "@clack/prompts";
const template = await p.select({
message: "Which template would you like?",
options: [
{ value: "fullstack", label: "Full-stack", hint: "Next.js + Express + all packages" },
{ value: "web", label: "Web only", hint: "Next.js + shared packages" },
{ value: "api", label: "API only", hint: "Express + database + auth" },
],
});Auto-detecting the package manager
One thing that frustrated me about other CLIs: they always defaulted to npm, even if you use pnpm. I added detection logic that checks which lock file exists in the current directory.
function detectPackageManager(): "npm" | "pnpm" | "yarn" | "bun" {
if (existsSync("pnpm-lock.yaml")) return "pnpm";
if (existsSync("yarn.lock")) return "yarn";
if (existsSync("bun.lockb")) return "bun";
return "npm";
}Small detail, but it's the kind of thing that makes a tool feel polished.
Scaffolding from codebase
The core of the CLI is code copying + variable substitution. The templates are generated and are copied to the target with project-name substitution applied.
CI/CD with GitHub Actions
I wanted the generated projects to ship with a working CI pipeline out of the box. The pipeline runs 5 jobs (lint, type-check, test, build, format-check) in parallel.
Docker Compose with Multi-Stage Builds
The generated Docker setup uses multi-stage builds to keep the production image small and secure.
FROM node:22-alpine AS base
FROM base AS builder
# Install system dependencies
RUN apk update && apk add --no-cache libc6-compat
# Install pnpm and manually configure PNPM_HOME
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm \
&& pnpm config set global-bin-dir "$PNPM_HOME" \
&& pnpm add -g turbo
# Set working directory
WORKDIR /app
COPY . .
RUN turbo prune api --docker
# Add lockfile and package.json's of isolated subworkspace
FROM base AS installer
RUN apk update && apk add --no-cache libc6-compat openssl ca-certificates curl
# Install pnpm and manually configure PNPM_HOME
ENV PNPM_HOME="/root/.local/share/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN npm install -g pnpm \
&& pnpm config set global-bin-dir "$PNPM_HOME" \
&& pnpm add -g turbo
WORKDIR /app
# First install dependencies (as they change less often)
COPY --from=builder /app/out/json/ .
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --ignore-scripts
# Build the project and its dependencies
COPY --from=builder /app/out/full/ .
# Generate Prisma client
RUN pnpm --filter @workspace/db db:generate
RUN pnpm turbo build
FROM base AS runner
WORKDIR /app
# Install curl for health checks
RUN apk add --no-cache curl
# Don't run production as root
RUN addgroup --system --gid 1001 expressjs
RUN adduser --system --uid 1001 expressjs
# Copy only production dependencies and built output
COPY --from=installer --chown=expressjs:expressjs /app/apps/api/dist ./apps/api/dist
COPY --from=installer --chown=expressjs:expressjs /app/packages/db/generated ./packages/db/generated
COPY --from=installer --chown=expressjs:expressjs /app/node_modules ./node_modules
COPY --from=installer --chown=expressjs:expressjs /app/apps/api/node_modules ./apps/api/node_modules
COPY --from=installer --chown=expressjs:expressjs /app/packages ./packages
USER expressjs
CMD ["node", "apps/api/dist/index.js"]The non-root user is a small thing that most tutorials skip. Running a container as root means a compromised process has root access to the host. It takes two lines to fix and it's a habit worth building early.
The Documentation Site
I built the docs with Fumadocs - a Next.js-based documentation framework. One thing I added that I haven't seen in many docs sites: LLM-friendly endpoints.
The idea is simple: developers increasingly use AI assistants to understand tools. If you structure your docs so a language model can read them cleanly (no sidebar noise, no JavaScript-rendered content, just the markdown), the AI gives better answers about your tool.
A developer can now point any AI at https:/build-elevate.vercel.app/llms.mdx/docs and get a clean, readable response. It's a small addition but it makes the tool feel genuinely modern.
What I'd Do Differently
Start with the template structure, not the CLI. I built the CLI first and had to refactor the templates three times as the CLI's scaffolding logic evolved. The templates are the product; the CLI is just the delivery mechanism.
Write tests earlier. The CLI has no unit tests. Every change required manually running the scaffold and checking the output. It's not a big project, but the lack of tests made refactoring slower than it needed to be.
Version the templates separately. Right now, template updates and CLI updates are coupled in the same release. If I added a breaking change to the fullstack template, I had to bump the whole package version. Separate versioning would have been cleaner.
What It Taught Me
Building BuildElevate taught me more about full-stack architecture than any tutorial I've watched.
When you build a template, you have to understand why every decision exists - not just how to use a tool, but what problem it solves and what the alternatives are. You become the person who has to explain it to someone else, which forces clarity.
It also taught me that the line between "project" and "product" is documentation. The first version of BuildElevate worked great. Nobody used it, because there was no README, no examples, no docs site. The code was the same. The documentation made it a tool other people could actually use.
Try It
pnpm dlx build-elevate@latestThe source code is on GitHub. If you find a bug, open an issue. If you build something with it, I'd genuinely love to see it.