Build a Project Scaffolding CLI Like create-next-app
Every modern framework ships a scaffolding CLI: create-next-app, create-vite, create-svelte. These tools generate entire project structures from templates, complete with dependencies, config files, and git initialization. They feel magical — but the patterns behind them are straightforward.
In this article, we'll build a scaffolding CLI from scratch that generates projects from templates, supports customization via prompts, and handles post-setup tasks like installing dependencies and initializing git.
What We're Building
scaffold — a CLI that:
- Offers multiple project templates
- Prompts for configuration (name, features, package manager)
- Copies and transforms template files
- Installs dependencies
- Initializes git
- Shows "what to do next" instructions
The Template Directory
Store templates as actual project directories:
templates/
basic/
package.json.hbs
src/
index.ts
tsconfig.json
.gitignore
api/
package.json.hbs
src/
server.ts
routes/
health.ts
tsconfig.json
.env.example
Dockerfile
Files ending in .hbs are Handlebars templates that get variable substitution. Everything else is copied as-is.
Step 1: Template Engine
// lib/template.ts
import { readFile, writeFile, readdir, mkdir, copyFile, stat } from 'node:fs/promises';
import { join, relative, dirname, extname } from 'node:path';
interface TemplateVars {
name: string;
description: string;
author: string;
features: string[];
packageManager: string;
[key: string]: unknown;
}
export async function renderTemplate(
templateDir: string,
outputDir: string,
vars: TemplateVars,
): Promise<string[]> {
const files: string[] = [];
await processDirectory(templateDir, outputDir, vars, files);
return files;
}
async function processDirectory(
srcDir: string,
destDir: string,
vars: TemplateVars,
files: string[],
) {
await mkdir(destDir, { recursive: true });
const entries = await readdir(srcDir, { withFileTypes: true });
for (const entry of entries) {
const srcPath = join(srcDir, entry.name);
let destName = entry.name;
// Remove .hbs extension from output
if (destName.endsWith('.hbs')) {
destName = destName.slice(0, -4);
}
// Template variable in filename: __name__ → actual name
destName = destName.replace(/__(\w+)__/g, (_, key) => String(vars[key] || key));
const destPath = join(destDir, destName);
if (entry.isDirectory()) {
// Skip directories based on features
if (entry.name === 'docker' && !vars.features.includes('docker')) continue;
if (entry.name === 'test' && !vars.features.includes('testing')) continue;
await processDirectory(srcPath, destPath, vars, files);
} else {
if (srcPath.endsWith('.hbs')) {
// Template file: render with variables
const content = await readFile(srcPath, 'utf-8');
const rendered = renderHandlebars(content, vars);
await writeFile(destPath, rendered);
} else {
// Static file: copy as-is
await copyFile(srcPath, destPath);
}
files.push(relative(destDir, destPath));
}
}
}
function renderHandlebars(template: string, vars: TemplateVars): string {
let result = template;
// Simple variable substitution: {{name}} → value
result = result.replace(/\{\{(\w+)\}\}/g, (_, key) => String(vars[key] || ''));
// Conditional blocks: {{#if feature}}...{{/if}}
result = result.replace(
/\{\{#if (\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
(_, key, content) => {
const value = vars[key] || vars.features?.includes(key);
return value ? content : '';
}
);
// Array iteration: {{#each items}}...{{/each}}
result = result.replace(
/\{\{#each (\w+)\}\}([\s\S]*?)\{\{\/each\}\}/g,
(_, key, content) => {
const arr = vars[key];
if (!Array.isArray(arr)) return '';
return arr.map(item => content.replace(/\{\{this\}\}/g, String(item))).join('');
}
);
return result;
}
Step 2: Post-Setup Tasks
// lib/setup.ts
import { execSync } from 'node:child_process';
import chalk from 'chalk';
import ora from 'ora';
export async function installDependencies(
dir: string,
packageManager: string,
): Promise<void> {
const spinner = ora(`Installing dependencies with ${packageManager}...`).start();
const commands: Record<string, string> = {
npm: 'npm install',
yarn: 'yarn',
pnpm: 'pnpm install',
bun: 'bun install',
};
try {
execSync(commands[packageManager] || 'npm install', {
cwd: dir,
stdio: 'pipe',
timeout: 120000,
});
spinner.succeed('Dependencies installed');
} catch (error) {
spinner.fail('Failed to install dependencies');
console.error(chalk.yellow(' Run the install command manually after setup'));
}
}
export async function initGit(dir: string): Promise<void> {
const spinner = ora('Initializing git repository...').start();
try {
execSync('git init && git add -A && git commit -m "Initial commit"', {
cwd: dir,
stdio: 'pipe',
});
spinner.succeed('Git repository initialized');
} catch {
spinner.fail('Git initialization failed');
}
}
export function showNextSteps(name: string, packageManager: string) {
const run = packageManager === 'npm' ? 'npm run' : packageManager;
console.log(chalk.bold('\n Your project is ready!\n'));
console.log(` ${chalk.cyan('cd')} ${name}`);
console.log(` ${chalk.cyan(`${run} dev`)} Start development server`);
console.log(` ${chalk.cyan(`${run} build`)} Build for production`);
console.log(` ${chalk.cyan(`${run} test`)} Run tests`);
console.log();
}
Step 3: The CLI
#!/usr/bin/env node
import { program } from 'commander';
import { input, select, checkbox, confirm } from '@inquirer/prompts';
import chalk from 'chalk';
import { existsSync } from 'node:fs';
import { renderTemplate } from '../lib/template.js';
import { installDependencies, initGit, showNextSteps } from '../lib/setup.js';
program
.name('scaffold')
.description('Generate projects from templates')
.argument('[name]', 'Project name')
.option('-t, --template <name>', 'Template to use')
.option('--pm <manager>', 'Package manager: npm, yarn, pnpm, bun')
.option('--no-install', 'Skip dependency installation')
.option('--no-git', 'Skip git initialization')
.option('-y, --yes', 'Accept all defaults')
.action(async (nameArg, options) => {
const interactive = !options.yes && process.stdout.isTTY;
// Project name
const name = nameArg || (interactive
? await input({ message: 'Project name:', default: 'my-app' })
: 'my-app');
if (existsSync(name)) {
console.error(chalk.red(` Directory "${name}" already exists`));
process.exit(1);
}
// Template selection
const template = options.template || (interactive
? await select({
message: 'Choose a template:',
choices: [
{ value: 'basic', name: 'Basic — TypeScript starter' },
{ value: 'api', name: 'API — Express REST server' },
{ value: 'cli', name: 'CLI — Command-line tool' },
{ value: 'fullstack', name: 'Full Stack — React + API' },
],
})
: 'basic');
// Features
const features = interactive
? await checkbox({
message: 'Select features:',
choices: [
{ value: 'eslint', name: 'ESLint', checked: true },
{ value: 'prettier', name: 'Prettier', checked: true },
{ value: 'testing', name: 'Vitest', checked: false },
{ value: 'docker', name: 'Docker', checked: false },
{ value: 'ci', name: 'GitHub Actions', checked: false },
],
})
: ['eslint', 'prettier'];
// Package manager
const pm = options.pm || (interactive
? await select({
message: 'Package manager:',
choices: [
{ value: 'npm', name: 'npm' },
{ value: 'pnpm', name: 'pnpm' },
{ value: 'yarn', name: 'yarn' },
{ value: 'bun', name: 'bun' },
],
})
: 'npm');
// Generate
console.log();
const templateDir = new URL(`../templates/${template}`, import.meta.url).pathname;
const files = await renderTemplate(templateDir, name, {
name,
description: `A ${template} project`,
author: '',
features,
packageManager: pm,
});
console.log(chalk.green(` ✓ Generated ${files.length} files`));
// Post-setup
if (options.install !== false) {
await installDependencies(name, pm);
}
if (options.git !== false) {
await initGit(name);
}
showNextSteps(name, pm);
});
program.parse();
The Template package.json
{
"name": "{{name}}",
"version": "0.1.0",
"private": true,
"description": "{{description}}",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"{{#if testing}},
"test": "vitest run",
"test:watch": "vitest"{{/if}}{{#if eslint}},
"lint": "eslint src/"{{/if}}
},
"dependencies": {
},
"devDependencies": {
"typescript": "^5.4.0",
"tsx": "^4.7.0"{{#if testing}},
"vitest": "^2.0.0"{{/if}}{{#if eslint}},
"eslint": "^9.0.0",
"@eslint/js": "^9.0.0",
"typescript-eslint": "^8.0.0"{{/if}}{{#if prettier}},
"prettier": "^3.3.0"{{/if}}
}
}
Why Build Your Own?
- Custom templates — match your team's exact stack and conventions
- Internal tooling — scaffold microservices, lambdas, or internal packages
- Opinionated defaults — bake in your preferred lint rules, CI config, and directory structure
- Learning — understand how create-next-app and create-vite actually work
Conclusion
Scaffolding CLIs follow a simple pattern: prompt → copy → transform → install → initialize. The template engine is the core — everything else is file operations and shell commands. Once you have this foundation, adding new templates is just creating new directories.
Wilson Xu builds developer CLI tools and scaffolding systems. Find his 12+ tools at npm.
Top comments (0)