Home / Blog / Build file-based theme inheritance module in Nuxt
Build file-based theme inheritance module in Nuxt

Build file-based theme inheritance module in Nuxt

Filip Rakowski
Filip Rakowski
Updated: December 8th 2024

We are used to certain ways of reusing code. Usually it happens through libraries and components with strictly defined APIs but sometimes thats not enough. Sometimes we want to reuse our whole codebase in a way that lets us adjust it to a special event like Black Friday or just make a slightly different version of our website for a specific country. In cases like that, we usually have very different needs, and being limited by previously defined extension points could make such tasks painful or even impossible.

Being able to automatically inherit all “standard”, unchanged files from a single source of truth and only adding what we want to change when making a new version of our website could drastically reduce the amount of maintained code. In this article, I will show you how to make all of that possible in Nuxt.js in a relatively simple way.

Taking inspiration from Nuxt itself

What makes Nuxt awesome and unique are out of the box features it comes with like file-based routing or Server Side Rendering (Universal Rendering). Have you ever wondered why these features doesn’t require you to write any additional code?

Most of the libraries deliver it’s features by exposing a third-party code you need to include into your app. The library is exposing a set of functions and objects that are considered its public API:

import { multiply } from 'fancy-math-library'
multiply(2,2) // 4 

Nuxt works a little bit different though. It takes advantage of having full control over the build process to hide the underlying complexity from its users.

Under the hood Nuxt is building new files on the fly based on directories like pages, components, plugins, etc. along with app configuration from nuxt.config.js. They are generated in the .nuxt folder.

Thanks to this approach you don’t see and interact with a very complex code that is responsible for functionalities that are heavily utilized by framework internals like server-side rendering, routing etc. You can influence how they work only through abstractions like file names in page folder. Because of that Nuxt can output more optimized code and avoid unnecessary breaking changes.

This is what happens when you change a single line of code in one of your Nuxt project files:

  1. .nuxt folder is flushed
  2. files inside .nuxt folder are recreated
  3. Vite HMR updates the changed part on the UI

So Nuxt is doing its own transformations, enrichments and merging of your files before standard Vite build is taking care of them. We will use the same strategy to solve our theme inheritance task.

What we have to do?

To make our inheritance mechanism work we will need 3 folders

  • base theme - one that we will inherit from
  • new theme where we will put files that should override ones from base directory
  • target code which will be used as a final directory that merges new and base directories. It will be our equivalent of .nuxt directory.
 ---| node_modules/
 ---| nuxt.config.js
 ---| package.json
 ---| assets/
 ---| base/
 ---| components/
 ---| layouts/
 ---| middleware/
 ---| new/
 ---| pages/
 ---| target/
 ---| plugins/
 ---| static/

Now when you think for a moment the inheriting itself turns out to be pretty straightforward:

  1. First we have to copy files from base theme to target directory.
  2. Then we have to copy files from new theme to target and override the ones that has conflicting names.

…and thats it! That's all you need to have a simple inheritance in place.

Of course it’s a poor developer experience because we will need to run the copying script every time we change something but don’t worry - in a few minutes we will try to make it better!

For now - lets keeps things simple.

Creating local Nuxt module

We will separate our feature from the rest of the app by isolating it in a Nuxt Module.

Nuxt Modules are commonly used to add third-party functionality into your Nuxt application. They can add all kinds of features like authentication, build process extensions or internationalization.

A Nuxt module is essentially just a function that runs every time we start our app from the CLI but what makes it powerful is the ability to extend and alter the configuration and the entire build.

A bare minimum for a working Nuxt module is just a simple function declaration:

// modules/theme-inheritance.js

export default function (moduleOptions) {
  console.log('Hello World!')
}

When adding modules to the /modules/ directory, Nuxt will auto-register them for your. The auto-registered files patterns are:

modules/*/index.ts
modules/*.ts

Now, if we run yarn dev or npm run dev we should see Hello World! in the console which means that our module has been successfully registered

TIP: You can transfer any data from your Nuxt app to the module by passing an array instead of a string to modules property. What you will pass will be accessible via moduleOptions inside your module. It’s a great way to make your module configurable.

modules: [
  [ '~/modules/theme-inheritance', { baseThemeDir: './custom-dir' } ]
],

Changing the root directory

By default Nuxt is using / directory as a source root. It means that it expect pages, components, plugins etc to be in the root directory of your app so we need to change where Nuxt is looking for these resources.

Nuxt modules have access to the configuration object of the project via this.options. Here we can change anything that can be putted in the nuxt.config.js.

To change the source root directory of your project you should change srcDir property.

// modules/theme-inheritance
const path = require('path')

export default function (moduleOptions) {
  this.options.srcDir = './target';
}

Now we need to move our resources into the base directory. If you’re wondering why we havn't moved our files to the target dir, remember that our module will automatically generate the files in this directory, so we should leave it empty.

 ---| node_modules/
 ---| nuxt.config.js
 ---| package.json
 ---| base/
 -----| assets/
 -----| components/
 -----| layouts/
 -----| middleware/
 -----| pages/
 -----| plugins/
 -----| static/
 ---| new/
 ---| target/

Nuxt will now look for pages, middlewares, plugins etc inside target folder instead of/.

Copying files

Next up, we need to copy files from base and new to the target directory. And also empty the target directory before we do so, to get rid of any deleted files.

To do both things we will use a great library fs-extra (one my favorite ones, really) which comes with a lot of useful file system related functions.

The ones that we will use are:

  • copy that copies a file or directory (recursively)
  • emptyDir that removes all files from a certain directory
yarn add fs-extra
const path = require('path')
const fse = require('fs-extra')

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base');
  const targetDir = path.join(this.options.rootDir, './target');
  const newDir = path.join(this.options.rootDir, './new');

  this.options.srcDir = './target';

  await fse.emptyDir(targetDir);
  await fse.copy(baseDir, targetDir)
  await fse.copy(newDir, targetDir)
}

Note: fse.copy is overriding conflicting files by default which is exactly what we need when copying them from new folder to target

The above 14 lines of code is basically a working theme inheritance mechanism but since it copies the files only when we start the dev/prod server it obviously needs to be improved and deliver better developer experience.

Watching file changes

It’s usually fairly easy to make some idea work but then it could take significantly more time to make it work in a way that won’t eventually drive other developers crazy. Despite an obviously high cost of better developer experience I strongly believe that such investment always pays back. Sometimes even literally if people decide to sponsor your work!

Thankfully in our case we can make our module more enjoyable to work with with just a few lines of code. An obvious killer of a good developer experience is the fact that we need to rerun the script every time we change or add a file in both base and new directories.

What I want to do, is to watch for file changes in our theme directories (base and new), and rerun our script when any changes are detected.

To watch file changes we will use chokidar which is a very popular library that is a wrapper over native fs.watch that simplifies a lot of things that would normally require much more code. Let see how we can use it!

const path = require('path')
const chokidar = require('chokidar');
const fse = require('fs-extra');

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base')
  const targetDir = path.join(this.options.rootDir, './target')
  const newDir = path.join(this.options.rootDir, './new')

  this.options.srcDir = './target';

  const copyFiles = async () => {
    await fse.emptyDir(targetDir);
    await fse.copy(baseDir, targetDir)
    await fse.copy(newDir, targetDir)
  }

  await copyFiles()

  chokidar.watch([baseDir, newDir]).on('all', async (event) => {
    if (event === 'add' || event === 'change' || event === 'unlink') {
      await copyFiles()
    }
  });
}

Let’s quickly review what happened here.

On line 20 we’re using chokidar.watch function to watch all changes in base and new directories. This function reacts to all possible file-related events but we need to take actions in case of only 3 of them (unlink means deletion) so we’re filtering them.

Every time we add or change a file in watched directories we’re just copying all of them again into target dir. As simple as that.

Even though it won’t be a problem for most of the apps if you will extend the above module with multiple levels of inheritance you may end up copying huge amounts of files which could take a few seconds. To speed things up instead of copying all the files you can use a filepath argument from chokidar and fse.copy to copy only the added/modified file.

const path = require('path')
const chokidar = require('chokidar');
const fse = require('fs-extra');

export default async function (moduleOptions) {
  const baseDir = path.join(this.options.rootDir, './base')
  const targetDir = path.join(this.options.rootDir, './target')
  const newDir = path.join(this.options.rootDir, './new')

  this.options.srcDir = './target';

  await fse.emptyDir(targetDir);
  await fse.copy(baseDir, targetDir)
  await fse.copy(newDir, targetDir)

  const toTargetPath = (oldPath) => {
    let newPath = oldPath
      // In case path contains "base" or "target" dirs before the Nuxt dir
      .replace(this.options.rootDir, '')
      .replace('/base/', '/target/').replace('/new/', '/target/')
    return path.join(this.options.rootDir, newPath)
  }

  chokidar.watch([baseDir, newDir]).on('all', async (event, filePath) => {
    if (event === 'add' || event === 'change') {
      fse.copy(filePath, toTargetPath(filePath))
    }
    if (event === 'unlink') {
      fse.remove(toTargetPath(filePath))
    }
  })
}

TIP: If you’re working with any kind of version control add target directory to .gitignore

How to improve?

The above technique can be even more powerful when you combine it with a templating engine like EJS. Your base theme could generate different versions of its files based on specific conditions.

This is exactly how Vue CLI worked in the previous version. The code generated from templates was different depending on the features you’ve checked in the CLI.

<template>
  <div id="app">
    <img src="./assets/logo.png">
    {{#router}}
    <router-view/>
    {{else}}
    <HelloWorld/>
    {{/router}}
  </div>
</template>

<script>
{{#unless router}}
import HelloWorld from './components/HelloWorld'
{{/unless}}
export default {
  name: 'App'{{#router}}{{else}},
  components: {
    HelloWorld
  }{{/router}}
}
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

You can see an example of such theme inheritance mechanism with EJS and Nuxt in Vue Storefront Next repository (here and here).

Summary

Having a directory-based inheritance mechanism can be really useful when we work with multiple themes within a single app. It also provides limitless possibilities when it comes to overriding and enhancing functionalities without worrying about limitations of existing extension points. With a small help of two 3rd party libraries and outstanding capabilities of Nuxt modules we can easily create a reusable module that enables an enjoyable theme inheritance experience.

Related Courses

Start learning Vue.js for free

Filip Rakowski
Filip Rakowski
Co-founder & CTO of Vue Storefront - Open Source eCommerce Platform for developers. I'm passionate about sharing my knowledge and thoughts on software architecture, web performance, Vue and raising awaraness about business impact of the technology and code we write. When I'm not working I'm probably playing with one of my cats.

Comments

Latest Vue School Articles

Effortless Data Generation with Faker.js: A Developer&#8217;s Guide

Effortless Data Generation with Faker.js: A Developer’s Guide

Faker.js is a powerful JavaScript library that generates realistic fake data for a variety of use cases like testing, database seeding, and data obfuscation. This article explores its features, practical applications, and how to integrate it into your projects with simple code examples.
Felipe Flor
Felipe Flor
What is Vue nextTick? Accessing the DOM after Data Updates

What is Vue nextTick? Accessing the DOM after Data Updates

Vue nextTick is useful when interacting with the DOM. This composition API function ensures the DOM has been re-rendered after data changes.
Daniel Kelly
Daniel Kelly

Our goal is to be the number one source of Vue.js knowledge for all skill levels. We offer the knowledge of our industry leaders through awesome video courses for a ridiculously low price.

More than 200.000 users have already joined us. You are welcome too!

Follow us on Social

© All rights reserved. Made with ❤️ by BitterBrains, Inc.