
Good day, friends!
In this article, I want to show you some of the capabilities of modern JavaScript and the interfaces provided by the browser related to routing and rendering pages without contacting the server.
Source code on GitHub .
You can play with the code at CodeSandbox .
Before proceeding with the implementation of the application, I would like to note the following:
- We implement one of the simplest client-side routing and rendering options, a couple of more complex and versatile (scalable, if you will) methods can be found here
- . : , .. , ( -, .. , ). index.html .
- Wherever possible and appropriate, we will use dynamic imports. It allows you to load only the requested resources (previously, this could only be done by splitting the code into parts (chunks) using module builders like Webpack), which has a good effect on performance. Using dynamic imports will make almost all of our code asynchronous, which, in general, is also good, since it avoids blocking the program flow.
So let's go.
Let's start with the server.
Create a directory, go to it and initialize the project:
mkdir client-side-rendering
cd !$
yarn init -yp
//
npm init -y
Install dependencies:
yarn add express nodemon open-cli
//
npm i ...
- express - Node.js framework that makes building a server much easier
- nodemon - a tool for starting and automatically restarting a server
- open-cli - a tool that allows you to open a browser tab at the address where the server is running
Sometimes (very rarely) open-cli opens a browser tab faster than nodemon starts the server. In this case, just reload the page.
Create index.js with the following content:
const express = require('express')
const app = express()
const port = process.env.PORT || 1234
// src - , , index.html
// , , public
// index.html src
app.use(express.static('src'))
// index.html,
app.get('*', (_, res) => {
res.sendFile(`${__dirname}/index.html`, null, (err) => {
if (err) console.error(err)
})
})
app.listen(port, () => {
console.log(`Server is running on port ${port}`)
})
Create index.html ( Bootstrap will be used for the main styling of the application ):
<head>
...
<!-- Bootstrap CSS -->
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/3.4.1/css/bootstrap.min.css" integrity="sha384-HSMxcRTRxnN+Bdg0JdbxYKrThecOKuH5zCYotlSAcp1+c8xmyTe9GYg1l9a69psu" crossorigin="anonymous" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<header>
<nav>
<!-- "data-url" -->
<a data-url="home">Home</a>
<a data-url="project">Project</a>
<a data-url="about">About</a>
</nav>
</header>
<main></main>
<footer>
<p>© 2020. All rights reserved</p>
</footer>
<!-- "type" "module" -->
<script src="script.js" type="module"></script>
</body>
For additional styling, create src / style.css:
body {
min-height: 100vh;
display: grid;
justify-content: center;
align-content: space-between;
text-align: center;
color: #222;
overflow: hidden;
}
nav {
margin-top: 1rem;
}
a {
font-size: 1.5rem;
cursor: pointer;
}
a + a {
margin-left: 2rem;
}
h1 {
font-size: 3rem;
margin: 2rem;
}
div {
margin: 2rem;
}
div > article {
cursor: pointer;
}
/* ! . */
div > article > * {
pointer-events: none;
}
footer p {
font-size: 1.5rem;
}
Add a command to start the server and open a browser tab in package.json:
"scripts": {
"dev": "open-cli http://localhost:1234 && nodemon index.js"
}
We execute this command:
yarn dev
//
npm run dev
Moving on.
Create a src / pages directory with three files: home.js, project.js, and about.js. Each page is a default exported object with "content" and "url" properties.
home.js:
export default {
content: `<h1>Welcome to the Home Page</h1>`,
url: 'home'
}
project.js:
export default {
content: `<h1>This is the Project Page</h1>`,
url: 'project',
}
about.js:
export default {
content: `<h1>This is the About Page</h1>`,
url: 'about',
}
Let's move on to the main script.
In it, we will use local storage to save and then (after the user returns to the site) obtain the current page and the History API to manage the browser history.
As for the storage, the setItem method is used to write data , which takes two parameters: the name of the stored data and the data itself, converted to a JSON string - localStorage.setItem ('pageName', JSON.stringify (url)).
To get data, use the getItem method , which takes the name of the data; the data received from the storage as a JSON string is converted to a regular string (in our case): JSON.parse (localStorage.getItem ('pageName')).
As for the History API, we will use two methods of the history object provided by the History interface : replaceState and pushState .
Both methods take two required and one optional parameters: a state object, title, and path (URL) - history.pushState (state, title [, url]).
The state object is used when handling the "popstate" event that occurs on the "window" object when the user transitions to a new state (for example, when the back button of the browser control bar is pressed) to render the previous page.
The URL is used to customize the path displayed in the browser address bar.
Please note that thanks to dynamic import, we load only one page when launching the application: either the home page, if the user visited the site for the first time, or the page that he last viewed. You can verify that only the resources you need are loading by examining the contents of the Network tab of the developer tools.
Create src / script.js:
class App {
//
#page = null
// :
//
constructor(container, page) {
this.$container = container
this.#page = page
//
this.$nav = document.querySelector('nav')
//
// -
this.route = this.route.bind(this)
//
//
this.#initApp(this.#page)
}
//
// url
async #initApp({ url }) {
//
// localhost:1234/home
history.replaceState({ pageName: `${url}` }, `${url} page`, url)
//
this.#render(this.#page)
//
this.$nav.addEventListener('click', this.route)
// "popstate" -
window.addEventListener('popstate', async ({ state }) => {
//
const newPage = await import(`./pages/${state.page}.js`)
//
this.#page = newPage.default
//
this.#render(this.#page)
})
}
//
//
#render({ content }) {
//
this.$container.innerHTML = content
}
//
async route({ target }) {
//
if (target.tagName !== 'A') return
//
const { url } = target.dataset
//
//
//
if (this.#page.url === url) return
//
const newPage = await import(`./pages/${url}.js`)
//
this.#page = newPage.default
//
this.#render(this.#page)
//
this.#savePage(this.#page)
}
//
#savePage({ url }) {
history.pushState({ pageName: `${url}` }, `${url} page`, url)
localStorage.setItem('pageName', JSON.stringify(url))
}
}
//
;(async () => {
//
const container = document.querySelector('main')
// "home"
const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'
//
const pageModule = await import(`./pages/${page}.js`)
//
const pageToRender = pageModule.default
// ,
new App(container, pageToRender)
})()
Change the h1 text in the markup:
<h1>Loading...</h1>
We restart the server.

Excellent. Everything works as expected.
So far, we've only dealt with static content, but what if we need to render pages with dynamic content? Is it possible in this case to be limited to the client or is this task only the server can do?
Let's assume that the main page is to display a list of posts. When you click on a post, the page with its content should be rendered. The post page should also persist in localStorage and render after page reload (close / open browser tab).
We create a local database in the form of a named JS module - src / data / db.js:
export const posts = [
{
id: '1',
title: 'Post 1',
text: 'Some cool text 1',
date: new Date().toLocaleDateString(),
},
{
id: '2',
title: 'Post 2',
text: 'Some cool text 2',
date: new Date().toLocaleDateString(),
},
{
id: '3',
title: 'Post 3',
text: 'Some cool text 3',
date: new Date().toLocaleDateString(),
},
]
Create a post template generator (also in the form of named export: for dynamic import, named export is somewhat more convenient than the default one) - src / templates / post.js:
//
export const postTemplate = ({ id, title, text, date }) => ({
content: `
<article id="${id}">
<h2>${title}</h2>
<p>${text}</p>
<time>${date}</time>
</article>
`,
// ,
// : `post/${id}`, post
//
//
url: `post#${id}`,
})
Create a helper function to find a post by its ID - src / helpers / find-post.js:
//
import { postTemplate } from '../templates/post.js'
export const findPost = async (id) => {
//
//
//
// ,
const { posts } = await import('../data/db.js')
//
const postToShow = posts.find((post) => post.id === id)
//
return postTemplate(postToShow)
}
Let's make changes to src / pages / home.js:
//
import { postTemplate } from '../templates/post.js'
//
export default {
content: async () => {
//
const { posts } = await import('../data/db.js')
//
return `
<h1>Welcome to the Home Page</h1>
<div>
${posts.reduce((html, post) => (html += postTemplate(post).content), '')}
</div>
`
},
url: 'home',
}
Let's fix src / script.js a little:
//
import { findPost } from './helpers/find-post.js'
class App {
#page = null
constructor(container, page) {
this.$container = container
this.#page = page
this.$nav = document.querySelector('nav')
this.route = this.route.bind(this)
//
//
this.showPost = this.showPost.bind(this)
this.#initApp(this.#page)
}
#initApp({ url }) {
history.replaceState({ page: `${url}` }, `${url} page`, url)
this.#render(this.#page)
this.$nav.addEventListener('click', this.route)
window.addEventListener('popstate', async ({ state }) => {
//
const { page } = state
// post
if (page.includes('post')) {
//
const id = page.replace('post#', '')
//
this.#page = await findPost(id)
} else {
// ,
const newPage = await import(`./pages/${state.page}.js`)
//
this.#page = newPage.default
}
this.#render(this.#page)
})
}
async #render({ content }) {
this.$container.innerHTML =
// , ,
// ..
typeof content === 'string' ? content : await content()
//
this.$container.addEventListener('click', this.showPost)
}
async route({ target }) {
if (target.tagName !== 'A') return
const { url } = target.dataset
if (this.#page.url === url) return
const newPage = await import(`./pages/${url}.js`)
this.#page = newPage.default
this.#render(this.#page)
this.#savePage(this.#page)
}
//
async showPost({ target }) {
//
// : div > article > * { pointer-events: none; } ?
// , , article,
// , .. e.target
if (target.tagName !== 'ARTICLE') return
//
this.#page = await findPost(target.id)
this.#render(this.#page)
this.#savePage(this.#page)
}
#savePage({ url }) {
history.pushState({ page: `${url}` }, `${url} page`, url)
localStorage.setItem('pageName', JSON.stringify(url))
}
}
;(async () => {
const container = document.querySelector('main')
const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'
let pageToRender = ''
// "post" ..
// . popstate
if (pageName.includes('post')) {
const id = pageName.replace('post#', '')
pageToRender = await findPost(id)
} else {
const pageModule = await import(`./pages/${pageName}.js`)
pageToRender = pageModule.default
}
new App(container, pageToRender)
})()
We restart the server.

The application works, but agree that the structure of the code in its current form leaves much to be desired. It can be improved, for example, by introducing an additional class "Router", which will combine the routing of pages and posts. However, we will go through functional programming.
Let's create another helper function - src / helpers / check-page-name.js:
//
import { findPost } from './find-post.js'
export const checkPageName = async (pageName) => {
let pageToRender = ''
if (pageName.includes('post')) {
const id = pageName.replace('post#', '')
pageToRender = await findPost(id)
} else {
const pageModule = await import(`../pages/${pageName}.js`)
pageToRender = pageModule.default
}
return pageToRender
}
Let's change src / templates / post.js a little, namely: replace the “id” attribute of the “article” tag with the “data-url” attribute with the value “post # $ {id}”:
<article data-url="post#${id}">
The final revision of src / script.js looks like this:
import { checkPageName } from './helpers/check-page-name.js'
class App {
#page = null
constructor(container, page) {
this.$container = container
this.#page = page
this.route = this.route.bind(this)
this.#initApp()
}
#initApp() {
const { url } = this.#page
history.replaceState({ pageName: `${url}` }, `${url} page`, url)
this.#render(this.#page)
document.addEventListener('click', this.route, { passive: true })
window.addEventListener('popstate', async ({ state }) => {
const { pageName } = state
this.#page = await checkPageName(pageName)
this.#render(this.#page)
})
}
async #render({ content }) {
this.$container.innerHTML =
typeof content === 'string' ? content : await content()
}
async route({ target }) {
if (target.tagName !== 'A' && target.tagName !== 'ARTICLE') return
const { link } = target.dataset
if (this.#page.url === link) return
this.#page = await checkPageName(link)
this.#render(this.#page)
this.#savePage(this.#page)
}
#savePage({ url }) {
history.pushState({ pageName: `${url}` }, `${url} page`, url)
localStorage.setItem('pageName', JSON.stringify(url))
}
}
;(async () => {
const container = document.querySelector('main')
const pageName = JSON.parse(localStorage.getItem('pageName')) ?? 'home'
const pageToRender = await checkPageName(pageName)
new App(container, pageToRender)
})()
As you can see, the History API, in conjunction with dynamic import, provide us with quite interesting features that greatly facilitate the process of creating single page applications (SPA) with almost no server involvement.
If you don't know where to start developing your application, then start with the Modern HTML Starter Template .
Recently I completed a small research on JavaScript design patterns. The results can be viewed here .
I hope you found something interesting for yourself. Thank you for attention.