Transferring Variables and Functions from Server to Client in a Node.js Application Using EJS and Eta

This post is about the process of seamlessly transfer variables and functions from server-side to client-side in your Node.js applications using EJS and Eta templating engines.

Intro

While developing a Node.js application, passing data from the server to the client is an inevitable checkpoint that you'll encounter.
To simplify this task (dynamic content generation), developers use templating engines such as EJS, Eta, Handlebars, Marko, Mustache, Nunjucks or Pug among many other.
I recommended EJS in the past but Eta has become my preferred template engine since I've discovered it two years ago.
Since Eta is a lightweight EJS alternative, faster and more configurable, this article covers the process for both of them.

Nota Bene: You'll find at the end of this article, in the Resources section, a link to a GitHub repository for the provided example.

Transfer process

The best way to pass data from the server to the client in a Node.js app using EJS or Eta goes like so:

  1. Server
    • Render a template with a data object on a route
  2. Server to Client
    • Stringify the data object in the template file inside a hidden element with an Id
  3. Client
    1. Get the hidden element by its Id in a JavaScript file (or <script> tag)
    2. Get the text content of the hidden element
    3. Parse the text content to retrieve the data object
    4. Remove the hidden element from the DOM

Let's illustrate this process with a simple example.
We need a web framework and EJS or Eta for our Node.js app.
I'll be using an enhanced version of Velocy and Eta.
So the only needed dependency here is Eta:

npm install eta # Or: npm i eta

In the entry file of our app index.js:

import { Eta } from "eta"
import { join } from "path"
import { createServer, Router } from "./router.js"

const app = new Router()
// Define the views folder as the root of Eta's templates
const eta = new Eta({ views: join(process.cwd(), "views") })

// Define an array to be transferred
const continentsArray = ["Africa", "Antarctica", "Asia", "Australia", "Europe", "North America", "South America"]

// Define a function to be transferred
const greetingFunction = () => {
    const greetingString = "Server says hello to Client!"
    return greetingString
}

// 1. Render a template with a data object on a route
app.get("/", (req, res) => {
    const response = eta.render("index.html", {
        continents: continentsArray,
        greeting: greetingFunction,
    })
    res.writeHead(200, { "Content-Type": "text/html" })
    res.end(response)
})

createServer(app).listen(5000, () => {
    console.log(`App @ http://localhost:${5000}`)
})

If you're wondering why I'm not using the gasworks called Express, read node_modules is not heavy, developers are lazy! and you'll have a different perspective while building a Node.js application.
Nota Bene: The number of Express dependencies have increased since then and Blog-Doc's dependencies have been reduced to 5!

At the root of our app, create a views folder, then inside it an index.html file and add the following:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>Data Transfer</title>
    </head>
    <body>
        <h1 id="greeting-heading"></h1>

        <div>
            <p>Those are the world's continents:</p>
            <ul id="continents-list"></ul>
        </div>

        <!-- 2. Stringify the data object in the template file inside a hidden element with an Id -->
        <span id="greeting" hidden><%= JSON.stringify(it.greeting()) %></span>
        <span id="continents" hidden><%= JSON.stringify(it.continents) %></span>

        <script>
            // 3.1 Get the hidden elements
            const greetingEl = document.getElementById("greeting")
            const continentsEl = document.getElementById("continents")

            // 3.2 Get hidden elements' text content
            const greetingText = greetingEl.textContent
            const continentsText = continentsEl.textContent

            // 3.3 Parse the text content
            const greeting = JSON.parse(greetingText)
            const continents = JSON.parse(continentsText)

            // 3.4 Remove hidden elements from the DOM
            greetingEl.remove()
            continentsEl.remove()

            // NOW WE CAN USE GREETING AND CONTINENTS!

            // Get greeting heading element
            const greetingHeading = document.getElementById("greeting-heading")

            // Use the greeting function as the inner text of the greeting heading
            greetingHeading.innerText = greeting

            // Get the continents' list element
            const continentsList = document.getElementById("continents-list")

            // Display each continent as a list item in the continents' list
            continents.forEach((continent) => {
                // Create a list item element for each continent
                let listItem = document.createElement("li")

                // Set the continent's name as the inner text of the created list item
                listItem.innerText = continent

                // Append the list item to the continents' list element
                continentsList.append(listItem)
            })
        </script>
    </body>
</html>

Hey! What is it?
Eta uses a global variable called it to store all the data that will be transferred to the templates.
It's a safer way to avoid naming collisions and improve performance.
it can also be modified, suppressed or used as a reference in the options.

Those of you who used EJS with Express know that you can't simply use a ".html" file to be rendered, it should be a ".ejs" file otherwise you'll have to map the EJS template engine to ".html" files:

app.engine("html", require("ejs").renderFile)

With Eta it's an out of the box functionality!

Now launch the server from your terminal, visit the link and see the magic!

node index
# You'll see after running this command
# App @ http://localhost:5000

Enhancing the process

This is good but not great for many reasons.
If we have multiple variables and/or functions to transfer, the third step of the process on the client-side will be gigantic and repetitive as seen in our simple example.
Also, in a real application, we would not use a <script> tag but a JavaScript file to load our scripting logic.
To optimize the procedure, we'll utilize a JavaScript file where the third step must be dynamic to prevent redundancy.

Using a minimal backend framework, such as Velocy, offers minimal functionalities as its description implies.
This doesn't mean that we can't extend it, as I did in its enhanced version, but loading static assets is another kind of challenge.
I'm not going to explain this other process, just copy the following code in a file called staticAssetLoader.js at the root of our app:

// Import necessary functions from the 'fs' module
import { readFileSync, readdirSync, statSync } from "fs"

// Import the 'path' module for path manipulation
import path from "path"

/**
 * StaticAssetLoader class for serving static assets from a directory.
 */
class StaticAssetLoader {
    /**
     * Constructor for StaticAssetLoader class.
     */
    constructor() {
        this.directory = "static" // Default directory
        this.staticAssets = this.getFiles(this.directory)
    }

    /**
     * Method to get files from their directory recursively.
     * @param {string} dirName - The directory path.
     * @returns {string[]} - Array of file paths.
     */
    getFiles(dirName) {
        let files = []
        const items = readdirSync(dirName, { withFileTypes: true })

        for (const item of items) {
            if (item.isDirectory()) {
                files = [...files, ...this.getFiles(`${dirName}/${item.name}`)]
            } else {
                files.push(`${dirName}/${item.name}`)
            }
        }

        return files
    }

    /**
     * Method to determine content type based on file extension.
     * @param {string} file - The file path.
     * @returns {string} - The content type.
     */
    getContentType(file) {
        const extname = path.extname(file)
        switch (extname) {
            case ".css":
                return "text/css"
            case ".js":
            case ".mjs":
                return "application/javascript"
            case ".png":
                return "image/png"
            case ".jpg":
            case ".jpeg":
                return "image/jpeg"
            case ".gif":
                return "image/gif"
            case ".avif":
                return "image/avif"
            case ".svg":
                return "image/svg+xml"
            case ".ico":
                return "image/x-icon"
            case ".webp":
                return "image/webp"
            default:
                return "application/octet-stream" // Default to binary data if the content type is not recognized
        }
    }

    /**
     * Method to serve static assets using Router app.
     * @param {object} app - The Router app instance.
     * @param {string} [directory="static"] - The directory containing static assets.
     */
    serveStaticAssets(app, directory = "static") {
        const staticAssets = this.getFiles(directory)

        staticAssets.forEach((el) => {
            app.get(`/${el}`, (req, res) => {
                const filePath = path.join(process.cwd(), `/${el}`)

                try {
                    const stats = statSync(filePath)
                    if (stats.isFile()) {
                        const contentType = this.getContentType(filePath)
                        res.setHeader("Content-Type", contentType)

                        const fileContents = readFileSync(filePath)
                        res.end(fileContents)
                    } else {
                        // If it's not a file, send a 404 Not Found response.
                        res.end("Not Found")
                    }
                } catch (err) {
                    console.error(`Error while serving file: ${err.message}`)

                    res.writeHead(302, { Location: "/500" })
                    res.end()
                    return
                }
            })
        })
    }
}

export const staticAssetLoader = new StaticAssetLoader()

Update the beginning of index.js:

import { Eta } from "eta"
import { join } from "path"
import { createServer, Router } from "./router.js"
import { staticAssetLoader } from "./staticAssetLoader.js"

const app = new Router()
// Define the views folder as the root of Eta's templates
const eta = new Eta({ views: join(process.cwd(), "views") })

// Serve static assets from default folder "static"
staticAssetLoader.serveStaticAssets(app)

// Rest of "index.js" remains the same

Replace the <script> tag and everything inside it in index.html with:

<script type="module" src="/static/main.js"></script>

Create a static folder at the root of our app, then create inside it 2 files:

  1. main.js
  2. parseHiddenJSON.js

Add the following to parseHiddenJSON.js:

/*
 * Retrieves hidden JSON data from a DOM element, parses it, and removes the element from the DOM.
 * @param {string} elementId - The ID of the DOM element containing the hidden JSON data.
 * @returns {Object|null} - The parsed JSON data, or null if the element is not found.
 */
export const parseHiddenJSON = (elementId) => {
    // Retrieve the DOM element with the given ID
    const element = document.getElementById(elementId)

    // If the element is found
    if (element) {
        // Extract the JSON data from the text content of the element
        const dataText = element.textContent
        // Parse the JSON data
        const data = JSON.parse(dataText)
        // Remove the element from the DOM
        element.remove()
        // Return the parsed JSON data
        return data
    } else {
        // Log an error if the element is not found
        console.error(`Element with ID '${elementId}' not found.`)
        // Return null
        return null
    }
}

Add the following to main.js:

import { parseHiddenJSON } from "./parseHiddenJSON.js"

// 3. Retrieve hidden JSON data from a DOM element, parse it, and remove the element from the DOM
const greeting = parseHiddenJSON("greeting")
const continents = parseHiddenJSON("continents")

// Get greeting heading element
const greetingHeading = document.getElementById("greeting-heading")

// Use the greeting function as the inner text of the greeting heading
greetingHeading.innerText = greeting

// Get the continents' list element
const continentsList = document.getElementById("continents-list")

// Display each continent as a list item in the continents' list
continents.forEach((continent) => {
    // Create a list item element for each continent
    let listItem = document.createElement("li")

    // Set the continent's name as the inner text of the created list item
    listItem.innerText = continent

    // Append the list item to the continents' list element
    continentsList.append(listItem)
})

The code is easy to understand, we created a function to dynamically do the redundant tasks in step 3 from the previous code, then we used it directly on the Id of the hidden element where the data object is stringified.
Cleaner code, and straightforward logic!

Please note that if we want to use a folder called "public" for our static assets instead of the default "static" folder, we would:

  1. Replace
// Serve static assets from default folder "static"
staticAssetLoader.serveStaticAssets(app)

by

// Serve static assets from custom folder name
staticAssetLoader.serveStaticAssets(app, "public")
  1. Replace
<script type="module" src="/static/main.js"></script>

by

<script type="module" src="/public/main.js"></script>
  1. Create a "public" folder at the root of the application and the needed files inside it.

Nota bene: The same logic applies to EJS, the only difference is that there is no it. in EJS!

Conclusion

This article demonstrates that we don't need heavy modules with unnecessary bells and whistles to achieve simple or complex tasks in a Node.js application.
We now master without any doubt the art of transferring variables and functions from the server to the client using EJS or Eta!

I hope that you'll find this article useful.

Resources

Link:

SYA,
LebCit.