Building a Component Generator CLI: scaffold, templates, plop
Stop copy-pasting component folders by hand. Here's how to build a CLI generator with plop.js and custom templates that scaffolds React + Tailwind components in seconds.
Why Hand-Writing Component Boilerplate Wastes Your Time
Honestly, creating the fifteenth component folder by hand — index.tsx, ComponentName.stories.tsx, ComponentName.test.tsx, styles.module.css — is a productivity sink that compounds every single sprint. It's not that the work is hard. It's that it's identical every time, and identical work is exactly what scripts are for.
Every component in a mature React codebase follows the same shape. You've got an export, some props typed with TypeScript, maybe a forwardRef, a default export, and a story file that covers the basic variants. Writing that from scratch each time introduces inconsistencies. Somebody forgets the displayName. Somebody skips the test file. Somebody names the style class differently than everyone else.
The fix isn't a style guide document nobody reads. It's a generator that enforces the pattern by construction. You run one command, you answer two prompts, and you get a perfectly structured folder every time. That's what this article builds.
Choosing Your Generator: plop.js vs Custom Node Scripts
You've got two real options here. Write a custom Node.js script with fs and readline, or use plop.js — a small tool (plop@4.0.1 at the time of writing) built on top of Handlebars and Inquirer. The custom script route gives you total control but you'll spend two hours wiring up prompts and file I/O before you've done any actual template work.
Plop is the faster path. It handles the prompt interface, the file writing, the string helpers (camelCase, pascalCase, kebabCase — all built in), and the action pipeline. You write a plopfile.mjs, define your generators, point them at Handlebars templates, and you're done. The entire setup takes maybe 30 minutes.
Where plop falls short: if you need to parse existing files and insert code into them (say, auto-registering a component in an index barrel file), you'll end up writing a custom action anyway. That's fine — plop's action API accepts plain functions. You can mix template actions and code actions in the same generator without any weirdness.
Setting Up plop in a Monorepo or Next.js Project
Install plop as a dev dependency at the repo root: npm install --save-dev plop@4.0.1. If you're in a monorepo, install it in the workspace root and run it from there via an npm script. Add "generate": "plop" to your root package.json scripts and you're set.
Create plopfile.mjs at the root. The .mjs extension matters if your package.json has "type": "module" — plop 4.x supports ES modules natively. If you're on CommonJS, use plopfile.js and module.exports = function(plop) { ... } instead. The examples below use ES module syntax.
// plopfile.mjs
export default function (plop) {
plop.setGenerator('component', {
description: 'Generate a new UI component',
prompts: [
{
type: 'input',
name: 'name',
message: 'Component name (PascalCase):',
},
{
type: 'list',
name: 'style',
message: 'Visual style:',
choices: ['glass', 'neumorphic', 'flat', 'gradient'],
},
],
actions: [
{
type: 'add',
path: 'src/components/{{pascalCase name}}/index.tsx',
templateFile: 'plop-templates/component/index.hbs',
},
{
type: 'add',
path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.stories.tsx',
templateFile: 'plop-templates/component/stories.hbs',
},
{
type: 'add',
path: 'src/components/{{pascalCase name}}/{{pascalCase name}}.test.tsx',
templateFile: 'plop-templates/component/test.hbs',
},
],
});
}That's the full generator. Three files, two prompts, zero manual work. Run npm run generate and plop's prompt interface walks you through it.
Writing Handlebars Templates for React and Tailwind Components
The template files live in plop-templates/component/. Each .hbs file is just a text file with Handlebars interpolations. Plop passes your prompt answers as template variables. The built-in helpers like {{pascalCase name}} and {{camelCase name}} do what you'd expect.
Here's a real component template that generates a Tailwind v4.0.2-compatible component with a conditional class based on the style prompt:
// plop-templates/component/index.hbs
import React from 'react';
import { cn } from '@/lib/utils';
const styleMap = {
glass: 'bg-white/10 backdrop-blur-md border border-white/20 rounded-2xl',
neumorphic: 'bg-zinc-100 shadow-[8px_8px_16px_#c8c8c8,-8px_-8px_16px_#ffffff] rounded-2xl',
flat: 'bg-zinc-900 border border-zinc-800 rounded-lg',
gradient: 'bg-gradient-to-br from-violet-500 to-fuchsia-500 rounded-2xl',
} as const;
type StyleVariant = keyof typeof styleMap;
export interface {{pascalCase name}}Props {
variant?: StyleVariant;
className?: string;
children?: React.ReactNode;
}
export const {{pascalCase name}} = React.forwardRef<
HTMLDivElement,
{{pascalCase name}}Props
>(({ variant = '{{style}}', className, children, ...props }, ref) => {
return (
<div
ref={ref}
className={cn(styleMap[variant], 'p-6', className)}
{...props}
>
{children}
</div>
);
});
{{pascalCase name}}.displayName = '{{pascalCase name}}';
export default {{pascalCase name}};Notice how '{{style}}' inlines the selected variant as the default prop value. That's a small thing but it means the generated component immediately reflects the choice you made at generation time. You can always change it, but the default is sensible out of the box. This kind of context-aware defaulting is where generators earn their keep over static snippets.
Auto-Registering Components in a Barrel Index File
Generating files is step one. Step two is making sure new components are actually importable from your library's main entry point. The pattern most teams use is a barrel file at src/components/index.ts that re-exports everything. Keeping it in sync manually is error-prone — you'll forget to add the export and spend 10 minutes debugging a missing module.
Plop's append action inserts a line into an existing file. Add this to your actions array after the file-creation actions:
{
type: 'append',
path: 'src/components/index.ts',
template: "export { {{pascalCase name}}, type {{pascalCase name}}Props } from './{{pascalCase name}}';",
}If your barrel file has a specific section marker (like // @generated-exports), you can use the pattern option to append after that line instead of at the end of the file. That keeps the generated exports grouped and easy to spot during code review. It's a small discipline that pays off on repos where the components/index.ts file is 300 lines long.
Adding Style-Specific Template Variants
What if your generator needs completely different template logic depending on the chosen style? A glassmorphism component (if you're not sure what that means, check out what is glassmorphism) uses backdrop-blur and rgba(255,255,255,0.1) backgrounds. A neumorphic one needs very specific box-shadow values like 8px 8px 16px rgba(0,0,0,0.15). The same template trying to handle both becomes unreadable fast.
The solution is conditional template selection using a custom plop action. Instead of type: 'add' with a fixed templateFile, write a function action that picks the template based on the prompt answer:
{
type: 'add',
path: 'src/components/{{pascalCase name}}/index.tsx',
templateFile: (answers) =>
`plop-templates/component/index.${answers.style}.hbs`,
}Now you maintain index.glass.hbs, index.neumorphic.hbs, etc. Each template is clean and focused. You can also pair this with Empire UI's own glassmorphism generator if you want to prototype the visual before scaffolding the component — grab the CSS values from the generator, drop them into your template, done. The same workflow works with the gradient generator for gradient-style components.
Validating Inputs and Preventing Naming Collisions
What happens when someone runs the generator and types a component name that already exists? Without validation, plop will refuse to overwrite by default (that's actually good behavior). But the error message is cryptic. A better approach is a custom validate function on the name prompt that checks the filesystem before proceeding.
{
type: 'input',
name: 'name',
message: 'Component name (PascalCase):',
validate: (input) => {
if (!input || input.trim().length === 0) {
return 'Component name is required';
}
const pascalName = input.charAt(0).toUpperCase() + input.slice(1);
if (!/^[A-Z][a-zA-Z0-9]+$/.test(pascalName)) {
return 'Use PascalCase: e.g. ButtonGroup, CardHeader';
}
const fs = await import('node:fs');
const targetPath = `src/components/${pascalName}`;
if (fs.existsSync(targetPath)) {
return `Component "${pascalName}" already exists at ${targetPath}`;
}
return true;
},
}The regex enforces PascalCase from the start. The fs check catches collisions early, before any files are written. It's a small addition that saves the annoying back-and-forth of running the generator, hitting an error, deleting the partial output, and re-running.
You should also validate the style choice if you're using it to select template files — a typo in a custom action that constructs a file path will throw a confusing Node error rather than a clean generator message. Adding choices to the list prompt (as shown in the plopfile above) handles this automatically.
Sharing Your Generator Across Teams with a CLI Package
If you're on a larger team or building an open-source component library, packaging the generator as a standalone CLI is worth doing. You publish an npm package, add a bin field to package.json pointing at a small entry script, and anyone can run npx your-org/generate-component without installing anything locally. That's the same pattern create-react-app, create-next-app, and many others use.
The entry script just resolves the plopfile path relative to the package and calls plop programmatically. The plop npm package exports a nodePlop function for exactly this use case. You don't need a plopfile in the consumer's project — the generator ships with the package.
One thing to think through: template customization. If you ship a generator but teams want to override specific templates, you'll need an escape hatch. A common pattern is a .generaterc.json in the consumer project that points to local template overrides. Your entry script merges those with the package defaults. It's maybe 40 lines of code and it makes the generator actually adoptable instead of a take-it-or-leave-it black box.
For teams building internal design systems, this is genuinely the better deployment model. You version the generator alongside the component library, so when you update the template (say, you adopt a new pattern for theme toggle in React support), everyone who upgrades the package dependency gets the updated scaffolding automatically.
FAQ
Yes. In your plopfile, make the output path a prompt or derive it from a workspace selection prompt. Use choices to list your packages, then construct the path dynamically: path: 'packages/{{workspace}}/src/components/{{pascalCase name}}/index.tsx'. Run plop from the repo root and it writes to whichever package the user selects.
Your stories.hbs template has access to the same prompt answers as the component template. Define a args block in the story that references variant with the chosen style as a default. You won't get full prop introspection from Handlebars — that requires a post-generation step — but for the common case of a variant + className + children prop shape, a well-written template covers 90% of what you need.
Not natively — plop expects a .js or .mjs file. The standard workaround is to write the plopfile in TypeScript, compile it as part of a build step, and point plop at the compiled output. Alternatively, keep the plopfile as plain JS and use JSDoc type annotations for editor hints. Most teams find the plain JS approach sufficient since plopfiles are rarely complex enough to need type safety.
add creates a single file from a single template. addMany reads all .hbs files from a template directory and generates them all at once, preserving directory structure. If your component always generates exactly the same set of files, addMany is cleaner. If the file set varies based on prompt answers (e.g., no story file for utility components), stick with individual add actions that you conditionally include using the skip function.
Add a check at the top of your plopfile that looks for a sentinel file — your package.json, a .root file, or a specific config. If it's not found, call process.exit(1) with a clear message. This prevents developers from accidentally running the generator from a subdirectory and having files written to unexpected locations.
Yes, via a custom function action at the end of the actions array. Use Node's child_process.execSync to run eslint --fix and prettier --write on the generated file paths. Wrap it in try/catch so a lint error doesn't fail the generator silently — log the error and let the developer fix it manually. Most teams add this as an optional step gated behind a --format flag parsed from process.argv.