How to build a static website without frameworks using npm scripts

Updated:

Sometimes it makes total sense to build an old fashioned static website. It can be not only faster but also simpler than throwing in a full JavaScript framework just to build a website with only a few pages. In the following, I'll create a template with scss, linting, minifying and more using npm scripts.

This not only helps you building a static website. It also helps understanding the tools that are used by frameworks and why. So let’s get started.

For the finished repository, which can be used as a template go to https://github.com/wwebdev/static-website-template

Initial Setup

Requirements:
Installed node.js / npm

First of all, I'll initialize an empty project by opening the console and typing npm init.
Then I create the initial index.html in the root directory:

<!DOCTYPE html>
<html lang="de">
    <head>
        <title>Static Website Template</title>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
    </head>

    <body>
        <h1>Static Website Template</h1>
    </body>
</html>

Now you can just open the index.html in your browser.
This doesn't look very exiting yet - so let's add some styling.

The CSS

For the CSS I will implement

  • Sass: This is a preprocessor, which will compile .scss files into CSS. With scss you can use variables, nesting, partials, modules and more. It makes writing CSS a lot easier, clearer and more modular.
  • Autoprefixer: This will add vendor prefixes, to improve the compatibility of your CSS for different browsers.
  • Linting: This will help to avoid errors in your CSS code and to enforce code conventions.

Sass

To be able to use all the fancy features of Sass, open the console, navigate to your project directory and type:

npm i -D node-sass

This will install the package node-sass, into the dev dependencies. It enables us to compile .scss files to CSS.
When the installation is done, open the package.jsonand add the following script to your scripts:

"css:scss": "node-sass --output-style compressed -o dist src/scss"

This will compile your scss from /src/scss into /dist. Additionally the --output-style compressed will remove all line-breaks and whitespaces to reduce the file size.
Now your package.json should then look like this:

{
    "name": "static-website",
    "version": "1.0.0",
    "description": "this is an example for a static website",
    "main": "index.js",
    "scripts": {
        "test": "echo \"Error: no test specified\" && exit 1",
        "css:scss": "node-sass --output-style compressed -o dist src/scss"
    },
    "author": "you",
    "license": "ISC",
    "devDependencies": {
        "node-sass": "^4.13.1"
    }
}

Now create the directory src and inside of src scss. Then you can create your first .scss file. I will name it index.scss

If you now run npm run css:scss in your console, it will compile your file into the dist folder. Your project structure should then look like this:

structure of the project

Now you can include the compiled CSS file in the <head>of your index.html

<link rel="stylesheet" type="text/css" href="dist/index.css">

To keep the scss organized, add a file _variables.scss to your /src/sass directory. The underscore at the beginning of the filename will make the file private and not being compiled. Now you can add some variables to this file like this:

$primary: #16a085;

To be able to use the variable $primary, you need to include _variables.scss in your index.scss:

@import 'variables.scss';

body {
    color: $primary;
}

Now run npm run css:scss again and refresh your browser to see the changes (We'll automate this step later).
I'd recommend to create new files (eg. _someModule.scss) for every part you create, to keep the scss organized. But I won't go into detail about the organization of CSS, as it is a big topic itself.

Autoprefixer

For implementing the autoprefixer we will use the npm module autoprefixer, together with postcss-cli. So go ahead and type in your console:

npm i -D autoprefixer postcss-cli

Then let's add the autoprefixer script to the scripts of your package.json

"css:autoprefixer": "postcss -u autoprefixer -r dist/*.css"

This will check your CSS files in the dist directory and add the prefixes for them. So you need to run npm run css:scss first, to compile your scss into distand afterward run npm run css:autoprefixer to add vendor-prefixes to your compiled CSS file. As we don't want to run the scripts one after another, let's add another script to thepackage.json, which will run the scripts sequentially by connecting the scripts with&&.

"build:css": "npm run css:scss && npm run css:autoprefixer"

By running npm run build:css, you will now run firstly npm run css:scss and afterward npm run css:autoprefixer.

Linting

Finally, we'll use stylelint to make sure we have no errors in our CSS and to be able to enforce code conventions.
Therefore install the npm module stylelint together with postcss-scss as we're using scss:

npm i -D stylelint postcss-scss

Before we're able to add the scripts for linting, we have to add the file .stylelintrc. This file will contain the rules, which stylelint should apply. For more information about the rules, you can use check the documentation.

"rules": {
    "block-no-empty": true,
    "color-hex-case": "lower",
    "color-hex-length": "short",
    "color-no-invalid-hex": true,
    "declaration-colon-space-after": "always",
    "max-empty-lines": 2
}

Afterward, we can add the script for linting and update our build:css to start with linting, so we catch errors before the file is compiled.

"css:lint": "stylelint src/scss/*.scss  --custom-syntax postcss-scss",
"build:css": "npm run css:lint && npm run css:scss && npm run css:autoprefixer"

Now npm run build:css should execute successfully (except if you have errors in your css). Next, I will add some automation, as we don't want to run the script manually everytime we change something.

Simplifying The Build

First I'll add a script, which will watch the /src/scss directory for changes and will run build:css, whenever something changes. For that, I will use onchange.

npm i -D onchange

Now add the script to the package.json:

"watch:css": "onchange \"src/scss\" -- npm run build:css"

If you now run npm run watch:css it should automatically run your build:css script whenever you change something in an scss file. Let's get rid of the manual browser refresh next. For this, we'll add browser-sync, to auto-refresh the browser. This will run a local server, which also enables us to test directly on other devices.
To install it run:

npm i -D browser-sync

Afterward, the corresponding script can be added to the package.json

"serve": "browser-sync start --server \"dist\" --files \"dist\""

As this will only watch the dist directory, so we need to move our index.html there. No worries, we'll have a better solution later, when we come to the HTML part :)

After moving the index.html, we need to update the stylesheet link to href="/index.css". If you now run npm run serveyour website should automatically open in the browser and you should see something like this in your console:

browser sync console output

This means you don't need to open the index.html to preview your website, but can just visitlocalhost:3000. This page will automatically refresh if something in your dist directory changes. If you want to see your website on other devices, you can do that now by opening the external url on your device.

And as a last step for the CSS, I will add a script to run the watch script and the browser-sync together. Sadly npm doesn't have a native way to run scripts in parallel for all operating systems. (the & operator only works on UNIX environments). Thus I'll install npm-run-all.

npm i -D npm-run-all

Afterward, we can add the script to the package.json

"watch": "run-p serve watch:css"

Now we're already in a good state for developing modern websites. By runningnpm run watch we are watching for changes in our directorysrc/scss and compile our scss into thedist directory. Also, we have autoprefixing and linting for our CSS in place. Additionally, the script is running a development server, which automatically refreshes our browser whenever something in distchanges.

Next, we'll have a look at how to add images to our build process.

The Images

The only thing we'll do here is adding a script, which will minify the images. This will improve the page speed as the images have a reduced file size. To do this, go to your console again and install imagemin-cli.

npm i -D imagemin-cli

Afterward, we can add the scripts for building and watching the directorysrc/images.

"build:images": "imagemin src/images/**/* --out-dir=dist/images",
"watch:images": "onchange \"src/images\" -- npm run build:images"

If you now run npm run build:images it will minify all images in your directory src/images and store them in dist/images. The watch script will look for changes in src/images and will run the build script whenever something changes. Now we'll add a build script. This will run all the scripts, which start with build:. Also let's update the watch script to run both, the watch:css and watch:images script.

"watch": "run-p serve watch:*",
"build": "run-p build:*"

The * operator makes the scripts run all other scripts starting with watch: orbuild:. If you have your npm run watch script still running, you need to restart it.

Now we're ready to integrate JavaScript on our website.

The JavaScript

Webpack & Babel

For JavaScript, I want to be able to use modern syntax without having to worry about browser compatibility. Therefore I'll use webpack together with babel. So let's install the required npm modules:

npm i -D webpack webpack-cli babel-loader @babel/preset-env

Afterward, we need to add a configuration file to make this work. So create a file webpack.config.js in your project root with the following content:

module.exports = {
    entry: './src/js/main.js',
    output: {
        path: __dirname + '/dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [{
            test: /\.m?js$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ['@babel/preset-env']
                }
            }
        }]
    }
}

Next, let's update the package.json with the scripts for our JavaScript build.

"build:js": "webpack --mode=production",
"watch:js": "onchange \"src/js\" -- webpack --mode=development"

This will use the development mode when we're watching the files and the production mode when we run npm run build. Now we can add our entry point for the js. This will be src/js/main.js. I'd recommend keeping the JavaScript modular by adding the logic into other files and include them in the main.js with the import syntax (eg. import './someModule')

Of course, we still need to include the bundled JavaScript at right before the </body> of our index.html.

<script src="./bundle.js"></script>

Linting

For JavaScript, it makes also sense to include a linter to keep the code more consistent and to avoid bugs. To introduce linting I'll use eslint.

npm i -D eslint eslint-webpack-plugin

To enable the linting, we still need to create a configuration file and add the module to our webpack configuration. First, let's create the configuration file .eslintrc in our project root. The following is the default configuration - you can, of course, change the rules according to your preferences.

If you want to read more about JavaScript linting, I'd recommend reading the blog post ESLint configuration and best practices

{
    "env": {
        "browser": true,
        "es6": true
    },
    "extends": "eslint:recommended",
    "globals": {
        "Atomics": "readonly",
        "SharedArrayBuffer": "readonly"
    },
    "parserOptions": {
        "ecmaVersion": 2018,
        "sourceType": "module"
    },
    "rules": {
        "semi": ["error", "always"],
        "quotes": ["error", "single"]
    }
}

Afterward, we the ESLintPlugin to our webpack.config.js to check eslint before running the babel-loader.

/* eslint-disable no-undef */
const ESLintPlugin = require('eslint-webpack-plugin');

module.exports = {
    entry: './src/js/main.js',
    plugins: [new ESLintPlugin()],
    output: {
        path: __dirname + '/dist',
        filename: 'bundle.js'
    },
    module: {
        rules: [{
            test: /.m?js$/,
            exclude: /node_modules/,
            use: {
                loader: 'babel-loader',
                options: {
                    presets: ['@babel/preset-env']
                }
            }
        }]
    }
};

Now our JavaScript is setup to use modern syntax and to check our code for errors and consistency. As the last thing, we will add HTML processing to our project.

The HTML

For the HTML I'll introduce posthtml, together with posthtml-modules. This will enable us to use HTML partials. This makes most sense if you're building a website with multiple pages, to reduce code duplication. Also, we'll add htmlnano to minify the HTML and reduce the file size.

npm i -D posthtml posthtml-cli posthtml-modules htmlnano

Then create the file posthtml.json in the project root, which will contain the configuration for the posthtml build.

{
    "input": "src/views/*.html",
    "output": "dist",
    "plugins": {
        "posthtml-modules": {
            "root": "./src/views",
            "initial": true
        },
        "htmlnano": {}
    }
}

This will tell posthtml to render all HTML files from src/views into dist. Now we only need to move the index.html from dist to src/views and update the package.json with the last scripts.

"build:html": "posthtml -c posthtml.json",
"watch:html": "onchange \"src/views\" -- npm run build:html"

Now we're able to use split our HTML into modules. For example I'll move the code from the head to a new file in src/views/components/head.html.

<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" type="text/css" href="/index.css">

Now we can add this module easily in every file by including

<module href="/components/head.html"></module>

So our final index.html for this demo should look like this:

<!DOCTYPE html>
<html lang="de">
    <head>
        <title>Static Website Template</title>
        <module href="/components/head.html"></module>
    </head>

    <body>
        <h1>Static Website Template</h1>

        <script src="./bundle.js"></script>
    </body>
</html>

That's it. The final project structure will look like this:

final structure of the project

The final repository can be found on GitHub.