DEV Community

Cover image for Create your custom npm template package
Donato Riccio
Donato Riccio

Posted on

Create your custom npm template package

a.k.a what npm create vite@latest does

Table of Contents

What we want

We want custom scaffolding to reduce the time needed to start a new project.
Just think about it: how often do you take the same three, four, or five steps to start a project?
Let's use Vue as an example:

  • install Vue
  • select maybe the extra packages (Vitest, Vue Router, Pinia)
  • add Bootstrap (or Tailwind)
  • add Sass
  • add Vuex (if you don't want Pinia)
  • add the simple state management (if you don't want Vuex)
  • remove unwanted components
  • add some basic components (i.e. HomePage for Vue Router)
  • setup Vue Router
  • and so on...

Instead of repeating all these steps every time, we want a way to have all of them already done on any new project!
Even more: we want the ability to keep our scaffolding updated in time.

How can we do this?

Before you continue

This tutorial is not a step-by-step guide on how to achieve the desired result. Is more of a: we can do things this way, it's possible and not so hard.
The code is publicly available on GitHub and also are the npm packages. Even more: try the packages before you dive into the tutorial.

See if this way of doing things is what you are looking for and only in this case keep reading the tutorial.
Just remember this is not a tutorial on how to do everything. I firmly believe that in some cases is better to learn things "the hard way" rather than have everything handed to you.

Try to make things work, struggle with Node errors, bash your head against the monitor, and close the day with at least a 1% of new things learned.

Try the package

Run this command to install a new VueJS customized template for your project:

npm create @ricciodev/templar@latest
Enter fullscreen mode Exit fullscreen mode

The easiest way (template repository)

One way to achieve the desired result is to create a GitHub repository and mark that repository as a template repository.

We can do this from the tab Settings, right under the repository name.

Image description

Now we can create a new repository on GitHub from this template repository. This means the new repository will have all the files included in the template, right from the start.

This is the easiest way to have a basic scaffolding ready to use.
The pros are: easy to do, and easy to maintain/update.
The cons are: if we need more than a couple of different scaffoldings, it becomes a bit hard to manage.

Of course, we can push things further!

NOTE
We will come back to this method later.

The npm create way

aka what Vite does

We can create a npm package with the sole purpose of generating a project already structured with all the files and dependencies we need.

Vite do this. Vue does this. Astro does this, and so on... the point being: that we already used this approach.
Now is the time to make our own package!

Before we start, we'll need just a bit of context.

The npm create command

The npm create <package-spec> command is only an alias for npm init <package-spec>.
All the information we need is available in the npm documentation

To sum it up we quote the first paragraph of the npm init documentation page:

npm init <initializer> can be used to set up a new or existing npm package.
initializer in this case is an npm package named create-<initializer>, which will be installed by npm-exec, and then have its main bin executed -- presumably creating or updating package.json and running any other initialization-related operations.

This means our package name will be prefixed with create-.
I.E. create-scaffolding-vue, create-custom-react or to make everything even more organized we could publish our package under a scope to avoid naming conflicts (@myorganization/create-react.)

Package requirements

We need to define some options in the package.json file.

main / exports

First, we set the entry point using the main key. The value will be the path to our main file (usually index.js.)
Later in this tutorial, we will drop main and use the exports field.

bin

We also want to define the bin key pointing to our executable file. For our package, we could use something like this:

{
  "bin": {
    "create-mypackage-name": "./index.js"
  }
}
Enter fullscreen mode Exit fullscreen mode

A really basic package

To start we need to initialize a project using the familiar npm init command.
This will create the basic scaffold for our package and we'll be able to set the requirements we saw in the previous step.

NOTE
If we want to create a scoped package now is the time to make it known to npm by adding our organization name before the package name.
I.E.
@myorg/create-mypackage-name instead of create-mypackage-name

NOTE
When creating a scoped package NPM will default the package to private visibility. To publish the package as public we need to use npm publish --access public.
More on this topic can be found in the notes at the end of this article.

Once the init procedure is done, let's add a new file called index.js (or main.js, or whatever your default naming convention is).
Then we meet the previous requirement: to define the main and the bin keys in package.json.

Both keys need to be defined at the root level of your package.json.
Let's point main to the file we just created:

{
    "main": "./index.js"
}
Enter fullscreen mode Exit fullscreen mode

Then we need to define the bin property pointing to the main executable file of our package.
The files defined here will be installed by npm into the PATH and be available to the package and the user.

The bin key is just a map of the command name to the local file name.

As per the official documentation, if we have only a single executable, and its name should be the name of the package, we could just use a string as the value for bin:

{
    "bin": "./index.js"
}
Enter fullscreen mode Exit fullscreen mode

If we want to be more verbose or we think we'll need to add more executables in the future, we can provide an object instead of a string, with a key named like our package (without the @org prefix).

{
    "bin": {
        "create-mypackage-name": "./index.js"
    }
}
Enter fullscreen mode Exit fullscreen mode

Both these examples are equivalent in our context.

A simple console.log

In our index.js add the following code:

#!/usr/bin/env node
console.log("Hello World!");
Enter fullscreen mode Exit fullscreen mode

NOTE
Please make sure that the file referenced in bin starts with #!/usr/bin/env node, otherwise the scripts won't be executed by node!

Make the package available globally on your local machine

We don't need to publish our package to test it.
Executing the npm link command inside the root of our project will make our local package available globally in our system.

Next, we need to create a new directory in another path (i.e. C:\test) and execute the command npm link create-mypackage-name or npm link @myorg/create-mypackage-name if you created a scoped package.
Now our package will be available in the current directory. Yay!

To be sure everything works as expected we need to type the following command in the terminal (from our test folder): npx create-mypackage-name.

NOTE
npx is not a typo, it's the command to run arbitrary commands from local or remote packages.
NPM Docs - npx

The name of the command is the same we defined inside the bin property in package.json.
If we can see the console.log in the terminal, everything is working properly!

NOTE
Now could be a great time to save our code in a repository on GitHub and publish our package.
This way we can try to install it using the npm create @myorg/mypackage-name@latest command and see if everything is ok.
If we see the console.log message after running our command, we are on the right track!

When creating a scoped package NPM will default the package visibility to private. To publish the package as public we need to use npm publish --access public.
More on this topic can be found in the following links.

In this tutorial we won't cover how to publish a package, but the following links will surely help you:
NPM Docs - Creating and publishing unscoped public packages
NPM Docs - Creating and publishing scoped public packages
freeCodeCamp - How to Create and Publish an NPM Package – a Step-by-Step Guide

How to manage a more complex scaffolding

Before we dive into the scaffolding package itself, it is important to understand how to edit the package.json to better organize our code and files.

Imagine we want to create a utility function in a file called toUppercase.js inside the utils folder.

Scaffolding:

index.js
package.json
utils
    toUppercase.js
Enter fullscreen mode Exit fullscreen mode

We want to use the toUppercase.js module inside index.js. To do so we need to import the file at the start of index.js but this will not be sufficient and will throw a ERR_MODULE_NOT_FOUND error.

If you published your package or you made it available globally on your local machine, go ahead and try.

To avoid this error we must update our package.json to export all the files we need and change the import inside index.js to be consistent with our package name.

Inside package.json let's add the exports property:

{
    "exports": {
        ".": "./index.js",
        "./utils/*.js": "./utils/*.js"
    },
}
Enter fullscreen mode Exit fullscreen mode

The official documentation states that:

The "exports" provides a modern alternative to "main" allowing multiple entry points to be defined, conditional entry resolution support between environments, and preventing any other entry points besides those defined in "exports".

NPM Docs - exports

This means we won't need the main field anymore.
In the exports field we defined:

  • the default entry point index.js
  • all other entry points inside the utils folder

In index.js we update our import by self-referencing our package using its name.
From:

import toUppercase from './utils/toUppercase.js';
Enter fullscreen mode Exit fullscreen mode

To:

import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js';
Enter fullscreen mode Exit fullscreen mode

This is not a necessary step and only works if we define the exports inside package.json.

Since we defined the property in exports with the .js extension, we must remember to explicitly state the extension when importing our modules.

This will throw an error:

import toUppercase from '@myorg/mypackage-name/utils/toUppercase'; // <-- NO EXTENSION
Enter fullscreen mode Exit fullscreen mode

This will work:

import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js'; // <-- WITH EXTENSION
Enter fullscreen mode Exit fullscreen mode

NOTE
If we already published our package we need to update its patch value and publish it again.
npm version patch will update the version (i.e. from 0.0.1 to 0.0.2).
npm publish will publish the updated version of the package, so that we can install it using npm create @myorg/mypackage-name@latest or npm create @myorg/mypackage-name@0.0.2

We can still test the package locally using the npm link command as before.

A simple scaffolding package

Now is the time to create our custom scaffolding package to bootstrap our projects!
We need to take a couple of minutes to think about how we want your projects to be developed:

  • Which framework do we want to use?
    • I.E. React? Vue? Astro?
  • What are the packages we use most often?
    • I.E. For Vue I find myself always installing Bootstrap, Sass, Vue Router, and Make JS Component
  • What are some common components or plugins that we always include in our project?
    • I.E. Header, Footer, Navigation, a simple state manager, some views...

Create a new project

When we have a clear idea of what we need the following step is to create this project step-by-step, as we did until today.
We create everything we need and nothing more. We do not want to add specific details to this project.

A crossroad

At this moment we are at a crossroads and in front of us have two ways to accomplish the same thing.

One will allow us to have everything in a single package, easy to maintain but it'll need a bit more tweaks to really shine (we won't cover these details right now).
It needs a bit of knowledge of NodeJS and JS in order to be developed and maintained. Other than that it's still a nice way to pack everything up.

The second way leverage NodeJS methods for executing commands, but requires us to create as many repositories as we need scaffolding variations.
On the other hand, this makes updating the scaffolding really easy, and doesn't need to make changes and update the main package in order to have those updates available for us and our users.
We need to execute one command using NodeJS execSync but the whole process is faster and the starter kits are easy to maintain.

Let's explore how every single method works.

NOTE
I will not, at this moment, focus on the meaning of single lines of code.
I will probably make a new tutorial dedicated to some of the functions used in the following parts.

One package, many files

First way, ready to go!

Pros:

  • single package to maintain
  • with a bit of NodeJS and NPM knowledge it could really shine
  • everything is in a single place
  • Vite also does things this way

Cons:

  • we need to copy/paste the files from a skeleton project
  • if we don't want to copy/paste we need to better understand how git, NodeJS, and NPM work
  • for every change in our scaffolding we need to publish a new version of the package

Update the scaffolding of the npm package

Back to our package!
We want to create a template directory, and if we want to be future proof we should also make a specific framework/library/tech directory.
In this example, we will create a vue directory.

This is the current scaffolding of our npm package:

index.js
package.json
utils
    toUppercase.js
template
    vue
Enter fullscreen mode Exit fullscreen mode

Inside the vue folder we will copy everything from the project we created in the previous step.

NOTE
Of course we will not copy the node_modules folder and neither the package-lock.json file.
A basic skeleton of a fresh new project will be enough.

How to interact with the user and copy the scaffolding files

NOTE
As mentioned before this part will be more of a copy/paste for now. If you are interested I will make a future article focusing on some of the methods and packages I used.

We need to ask our users about the project our package will create.
To accomplish this task we need the inquirer package (the following code is referencing the linked version, but feel free to use the new version of inquirer and edit the code example).

After installing inquirer we need to update our main entry point, index.js:

#!/usr/bin/env node

import toUppercase from '@myorg/mypackage-name/utils/toUppercase.js';
import inquirer from 'inquirer';

(async () => inquirer
    .prompt([
        {
            type: "input",
            name: "projectName",
            message: "Enter your project name",
            default: "obi-wan-kenobi"
        }
    ])
    .then((answers) => {
        console.log(toUppercase(answers.projectName));
    })
    .catch((error) => {
        if (error.isTtyError) {
            // Prompt couldn't be rendered in the current environment
            console.error("Cannot render the prompt...");
        } else {
            console.error(error.message);
        }
    }))();
Enter fullscreen mode Exit fullscreen mode

Running our project (locally or after publishing a new version and installing it again) will give us a nice prompt inside our terminal, asking for the name of our shiny new project.

Image description
Perfection!

Instead of printing the project name all in uppercase inside the console, we will copy the files and folders from template/vue inside the current project directory.

We must create a new file inside the utils folder: createProject.js.
Inside this file we will add two functions:

  • a init function that will start the whole process
  • a copyTemplateFilesAndFolders function that will recursively copy all the files and folders from the template directory
import fs from 'fs/promises';
import { fileURLToPath } from "node:url";
import path from "node:path";
import chalk from 'chalk';

export const copyTemplateFilesAndFolders = async (source, destination, projectName) => {
    const filesAndFolders = await fs.readdir(source);

    for (const entry of filesAndFolders) {

        const currentSource = path.join(source, entry);
        const currentDestination = path.join(destination, entry);

        const stat = await fs.lstat(currentSource);

        if (stat.isDirectory()) {

            await fs.mkdir(currentDestination);
            await copyTemplateFilesAndFolders(currentSource, currentDestination);

        } else {

            // If the file is package.json we replace the default name with the one provided by the user
            if (/package.json/.test(currentSource)) {
                const currentPackageJson = await fs.readFile(currentSource, 'utf8');
                const newFileContent = currentPackageJson.replace(/custom-scaffolding/g, projectName);

                await fs.writeFile(currentDestination, newFileContent, 'utf8');
            } else {
                await fs.copyFile(currentSource, currentDestination);
            }

        }
    }
};

export const init = async (projectName) => {

    const destination = path.join(process.cwd(), projectName);

    const source = path.resolve(
        path.dirname(fileURLToPath(import.meta.url)),
        "../template/vue"
    );

    try {
        console.log('📑  Copying files...');

        await fs.mkdir(destination);
        await copyTemplateFilesAndFolders(source, destination, projectName);

        console.log('📑  Files copied...');
        console.log(chalk.green(`\ncd ${projectName}\nnpm install\nnpm run dev`));
    } catch (error) {
        console.log(error);
    }
};
Enter fullscreen mode Exit fullscreen mode

To make the message more pretty in the terminal we could install chalk.
This is not a mandatory step but a nice one. Of course, if we don't want to use this library it's sufficient to remove all references to it from the previous code.

Just a bit of context before we continue.
The previous code has two functions. The init function receives the project name and with the help of the path module we create a path (of course) pointing to a folder named like the project name we provided, concatenated to our current working folder (this comes from process.cwd()).
The join methods will use the correct path separator based on the OS the package is installed on.

const destination = path.join(process.cwd(), projectName);
Enter fullscreen mode Exit fullscreen mode

With the help of the fs module NodeJS can use the method mkdir to create a new folder:

await fs.mkdir(destination);
Enter fullscreen mode Exit fullscreen mode

We need to resolve the path to our scaffolding files and folders. To do this we will use the resolve method of the path module:

const source = path.resolve(
    path.dirname(fileURLToPath(import.meta.url)),
    "../template/vue"
);
Enter fullscreen mode Exit fullscreen mode

I leave to the official documentation and to your curiosity the explanation for this bit ;)

The copyTemplateFilesAndFolders will be called with three arguments: source (where the template is), destination (the folder just created and named after what we typed in the prompt), projectName (the name of our project).

copyTemplateFilesAndFolders will then copy every single folder and file from the source to the destination. For each folder it will recursively call itself to copy every file from it.

When the function finds the package.json file it will overwrite the default project name with the one we provided.

NOTE
We could also parse the file content as JSON and overwrite the name property using direct access.
We will then re-encode the object as JSON string and write it back to the file.

Now it's a good time to test again our package and see if everything works correctly.
If the test with the local package is showing you index.js instead of executing it, just unlink and re-link the project.

With this step, we are done! Now we can create a new project with everything we want, using a single npm command.

One package, many repos

This is the last method to tackle this problem.

Pros:

  • it's easier to add a new scaffolding and to keep things up to date
  • only one single package to update and publish
  • starter kit repos are easy to maintain

Cons:

  • you don't really need a npm package to install a git repo, so it's a bit of an extra step

A new old repo

First things first!
Remember the template repository we saw at the start of this tutorial? We are going back to it and will sprinkle a bit of NodeJS and NPM magic dust over it.

How to interact with the user and install the starter kit

As we did for the other version, we need to use inquirer to ask our users (or our future selves) the name of the project and what kit we want to use.

Since we want to be able to define more than one starter kit and we want things to be tidy and clean, we should create a new file inside the utils folder called allowedPackages.js.
This package will define the scope (our GitHub username -- my-github-username), the starter kit (an array of strings), and a method that will create an array of allowed packages (this will come in handy later for security purposes).

Here's the content of the file:

export const scope = 'my-github-username';

export const starterKits = [
    'vue'
];

export function allowedPackages() {
    return starterKits.map(kit => `${scope}/${kit}-starter-kit`);
}
Enter fullscreen mode Exit fullscreen mode

We need to update index.js to import this file and to ask our users which kit they want to install.
Here's the updated index.js:

#!/usr/bin/env node

import { init } from '@myorg/mypackage-name/utils/createProject.js';
import { allowedPackages, starterKits, scope } from '@myorg/mypackage-name/utils/allowedPackages.js';
import inquirer from 'inquirer';

(() => inquirer
    .prompt([
        {
            type: "input",
            name: "projectName",
            message: "Enter your project name",
            default: "obi-wan-kenobi"
        },
        {
            type: "list",
            name: "prefix",
            message: "Select a starter kit",
            choices: [...starterKits]
        }
    ])
    .then((answers) => {
        const requestedPackage = `${scope}/${answers.prefix}-starter-kit`;

        if (!allowedPackages().includes(requestedPackage)) {
            throw new Error("Invalid package");
        }
        
        init(answers.projectName, requestedPackage);
    })
    .catch((error) => {
        if (error.isTtyError) {
            // Prompt couldn't be rendered in the current environment
            console.error("Cannot render the prompt...");
        } else {
            console.error(error.message);
        }
    }))();
Enter fullscreen mode Exit fullscreen mode

We added a new option to our users: what starter kit they want.
Note that we check if the selected package is valid. If not it means the user tried to send some arbitrary value and the script will throw an error and stop the execution.

Of course, the createProject.js module changed as well:

import fs from 'fs/promises';
import path from "node:path";
import chalk from 'chalk';
import { execSync } from 'node:child_process';

const updatePackageJson = async (projectPath, requestedPackage, projectName) => {

    console.log(chalk.gray('Updating package.json...'));

    const packagePath = path.join(projectPath, 'package.json');
    const stat = await fs.lstat(packagePath);

    if (stat.isFile()) {
        const currentPackageJson = await fs.readFile(packagePath, 'utf8');
        const newFileContent = currentPackageJson.replace(requestedPackage, projectName);
        await fs.writeFile(packagePath, newFileContent, 'utf8');
    }
}

const removeGitFolder = async (projectPath) => {

    console.log(chalk.gray('Removing .git folder...'));

    const gitFolderPath = path.join(projectPath, '.git');
    const stat = await fs.lstat(gitFolderPath);

    if (stat.isDirectory()) {
        await fs.rm(gitFolderPath, {
            recursive: true
        });
    }
}

export const init = async (projectName, requestedPackage) => {

    try {

        const currentDir = process.cwd();
        const destination = path.join(currentDir, projectName);
        const fullURL = `https://github.com/${requestedPackage}.git`

        console.log(chalk.gray('📑  Copying files...'));

        await fs.mkdir(destination);
        execSync(`git clone --depth 1 ${fullURL} .`, { stdio: "inherit", cwd: destination });

        console.log(chalk.gray('📑  Files copied...'));

        await removeGitFolder(destination);
        await updatePackageJson(destination, requestedPackage, projectName);

        console.log(chalk.green(`\ncd ${projectName}\nnpm install\nnpm run dev`));
    } catch (error) {
        console.log(chalk.red(error));
    }
};
Enter fullscreen mode Exit fullscreen mode

On this one, we have simplified a couple of things.
The copyTemplateFilesAndFolders doesn't exist anymore. This time we need two functions to update the default package.json we are getting from the starter kit repo and a function to remove the .git folder from the cloned repo.

The init function will use execSync to execute the git clone command for our starter kit repo:

git clone --depth 1 ${fullURL} .
Enter fullscreen mode Exit fullscreen mode

This will clone the repo in the project folder defined by the cwd property in the second parameter of the execSync command:

{ stdio: "inherit", cwd: destination }
Enter fullscreen mode Exit fullscreen mode

NOTE
The exec and execSync methods of NodeJS use a shell to execute our command. Since we are also adding some user-provided data we need to be careful not to allow arbitrary commands to be executed (i.e. rm -rf). This is the reason for our previous validation inside index.js.

If the user sends an invalid selection (i.e. not a valid starter kit, some arbitrary commands, etc...), our code will not allow that string to be passed to execSync and be executed. We will stop everything and be safe.

As before we update the package.json file so the name of the project will be the same one the user provided.

Conclusion

I decided to make this tutorial for a reason: to show how we can make things following the example set by others (Vite in this specific case) and how we can add some of our needs on top of that.

This tutorial doesn't want to be a comprehensive explanation of how to create this kind of package. As I said before is more like a finger pointing in a direction. If you want, follow that direction, make your own mistakes along the way, and learn.

At the end of that path, you will find knowledge and maybe you'll have your own personal scaffolder!

If this article was helpful or want to start a conversation, feel free to reach out in the comments or on LinkedIn

I'll be happy to receive any feedback or ideas for future articles and tutorials.

Resources

Top comments (0)