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:
-
Server
- Render a template with a data object on a route
-
Server to Client
- Stringify the data object in the template file inside a hidden element with an Id
-
Client
-
Get the hidden element by its Id in a JavaScript file (or
<script>
tag) - Get the text content of the hidden element
- Parse the text content to retrieve the data object
- Remove the hidden element from the DOM
-
Get the hidden element by its Id in a JavaScript file (or
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:
- main.js
- 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:
- Replace
// Serve static assets from default folder "static"
staticAssetLoader.serveStaticAssets(app)
by
// Serve static assets from custom folder name
staticAssetLoader.serveStaticAssets(app, "public")
- Replace
<script type="module" src="/static/main.js"></script>
by
<script type="module" src="/public/main.js"></script>
- 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: