Introduction
Building modern web applications doesn’t always require heavy frameworks like React or Angular. With vanilla JavaScript, a lean router, and Webpack bundling, you can create a minimal Single Page Application (SPA) that is fast, maintainable, and extensible.
This tutorial walks you through setting up a lean SPA framework that follows the MVP (Model–View–Presenter) pattern. It uses:
- Layout and page views
- Vanilla JS with template literals for views
- page.js router with authentication callbacks
- Cached rendering and state management on top of localStorage
- Bootstrap SCSS with Webpack bundling
- Async fetch with loading spinners
By the end, you’ll have a data-driven SPA ready for extension into complex applications.
Prerequisites
- Ubuntu (20.04 or later recommended)
- Node.js and npm installed
- Basic knowledge of JavaScript and HTML
Step 1: Install Node.js and npm on Ubuntu
Open your terminal and run:
# Update system packages
sudo apt update && sudo apt upgrade -y
# Install Node.js (LTS version)
sudo apt install -y nodejs npm
# Verify installation
node -v
npm -vCode language: PHP (php)Step 2: Install Webpack and Helpers
We’ll use Webpack with merge configs, SCSS loaders, and HTML helpers.
# Create project folder
mkdir lean-spa-framework && cd lean-spa-framework
# Initialize npm project
npm init -y
# Install dependencies
npm install page bootstrap
# Install dev dependencies
npm install --save-dev webpack webpack-cli webpack-dev-server webpack-merge \
html-webpack-plugin style-loader css-loader sass-loader sass mini-css-extract-pluginCode language: PHP (php)Step 3: Project Directory Structure
Here’s the minimal layout:
lean-spa-framework/
├── package.json
├── webpack.common.js
├── webpack.dev.js
├── webpack.prod.js
└── src/
├── index.html
├── index.js
├── spa.js
├── style.scss
├── images/
│ ├── logo.png
│ └── banner.jpg
└── views/
├── layout.js
├── home.js
├── products.js
└── login.jsindex.html→ Shell file, injected by Webpack’sHtmlWebpackPluginindex.js→ Entry point, defines routes and handlersspa.js→ Core framework (state, rendering, auth, caching)style.scss→ Bootstrap SCSS + custom overridesviews/→ Templates for layout, home, products, login
Step 4: Webpack Configuration
We’ll use three configs: common, dev, and prod.
webpack.common.js
import path from "path";
import HtmlWebpackPlugin from "html-webpack-plugin";
export default {
entry: "./src/index.js",
output: {
filename: "[name].js",
path: path.resolve("dist"),
clean: true
},
module: {
rules: [
{ test: /\.(png|jpe?g|gif|svg)$/i, type: "asset/resource" }
]
},
plugins: [
new HtmlWebpackPlugin({
template: "./src/index.html",
inject: "body"
})
],
};Code language: JavaScript (javascript)webpack.dev.js
import { merge } from "webpack-merge";
import common from "./webpack.common.js";
export default merge(common, {
mode: "development",
devtool: "inline-source-map",
module: {
rules: [
{
test: /\.scss$/,
use: [
"style-loader",
"css-loader",
{
loader: "sass-loader",
options: {
sassOptions: {
quietDeps: true,
silenceDeprecations: ["import"],
}
}
}
]
}
]
},
devServer: {
static: "./dist",
hot: true,
historyApiFallback: true,
setupMiddlewares: (middlewares, devServer) => {
devServer.app.get("/api/home", (req, res) => {
res.json({ message: "Hello from API" });
});
devServer.app.get("/api/products", (req, res) => {
res.json({
products: [
{ name: "Laptop", price: 1200 },
{ name: "Phone", price: 800 }
]
});
});
return middlewares;
}
}
});Code language: JavaScript (javascript)webpack.prod.js
import { merge } from "webpack-merge";
import MiniCssExtractPlugin from "mini-css-extract-plugin";
import common from "./webpack.common.js";
export default merge(common, {
mode: "production",
devtool: false,
module: {
rules: [
{
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
"css-loader",
{
loader: "sass-loader",
options: {
sassOptions: {
quietDeps: true,
silenceDeprecations: ["import"],
}
}
}
]
}
]
},
plugins: [
new MiniCssExtractPlugin({
filename: "[name].[contenthash].css"
})
],
optimization: {
splitChunks: { chunks: "all" }
}
});Code language: JavaScript (javascript)Step 5: npm Scripts
Add these to package.json:
"scripts": {
"start": "webpack serve --config webpack.dev.js --mode development",
"build": "webpack --config webpack.prod.js --mode production"
}Code language: JavaScript (javascript)Now you can run:
npm start # Development server
npm run build # Production buildCode language: PHP (php)Core SPA Logic (src/spa.js)
The SPA core is where the framework’s power lies. It provides:
- State management on top of
localStorage - Rendering with Optional API calls via the
render()function - API data caching (data is cached after first page load)
- Authentication guard handled by
page.jsmiddleware - Login/logout
State info such as login information is automatically injected and made available to templates alongside any API data.
import page from "page";
const cache = {}; // cache only for API responses
// Clear cache
cache.clear = function () {
for (const key in cache) {
if (key !== "clear") {
delete cache[key];
}
}
};
const initialState = {
view: null,
isAuthenticated: false,
userName: null
};
// Initialize localStorage with defaults
for (const [key, value] of Object.entries(initialState)) {
if (localStorage.getItem(key) === null) {
localStorage.setItem(key, JSON.stringify(value));
}
}
export const state = {
set(key, value) {
localStorage.setItem(key, JSON.stringify(value));
},
get(key) {
const raw = localStorage.getItem(key);
return raw ? JSON.parse(raw) : null;
},
clear() {
for (const [key, value] of Object.entries(initialState)) {
localStorage.setItem(key, JSON.stringify(value));
}
}
};
export async function layout(tmpl, url = null) {
const app = document.getElementById("app");
if (!app) throw new Error("No #app mount target found in DOM.");
state.set("view", "#app");
await render(tmpl, url);
state.set("view", "#view");
}
function showLoading() {
const viewSelector = state.get("view");
const view = viewSelector ? document.querySelector(viewSelector) : null;
if (view) {
view.innerHTML = `
<div class="d-flex flex-column align-items-center justify-content-center py-5">
<div class="spinner-grow text-primary" role="status" aria-hidden="true"></div>
</div>
`;
}
}
/**
* Render function:
* - Accepts a template function
* - Optionally takes a URL
* - If URL is provided, fetches data from API
* - API responses are cached after the first load
* - Injects state info (login info, etc.) into templates
*/
export async function render(tmpl, url = null) {
const viewSelector = state.get("view");
const view = viewSelector ? document.querySelector(viewSelector) : null;
if (!view) throw new Error("No mount target set. Call layout() first.");
let data = {};
if (url) {
const key = encodeURIComponent(url);
try {
if (!cache[key]) {
showLoading();
const res = await fetch(url);
if (!res.ok) throw new Error(`Failed to load ${url}: ${res.status}`);
cache[key] = await res.json();
}
data = cache[key];
} catch (err) {
console.error("Render error:", err);
view.innerHTML = `
<section class="error">
<h2>Oops, something went wrong</h2>
<p>${err.message}</p>
</section>
`;
return;
}
}
const mergedData = {
...data,
userName: state.get("userName"),
isAuthenticated: state.get("isAuthenticated")
};
view.innerHTML = tmpl(mergedData);
}
// Authentication guard handled by page.js
export function requireAuth(ctx, next) {
const isAuthenticated = state.get("isAuthenticated");
if (!isAuthenticated) {
page.redirect("/login");
} else {
next();
}
}
// Login/logout handled in SPA core
export function login(username) {
state.set("isAuthenticated", true);
state.set("userName", username);
cache.clear();
}
export function logout() {
state.set("isAuthenticated", false);
state.set("userName", null);
cache.clear();
}
export { page };Code language: JavaScript (javascript)Views
src/views/layout.js
export default function layout({ isAuthenticated, userName }) {
return `
<nav>
<a href="/">Home</a>
<a href="/products">Products</a>
${
isAuthenticated
? `<a href="/logout">Logout (${userName})</a>`
: `<a href="/login">Login</a>`
}
</nav>
<main id="view"></main>
`;
}Code language: HTML, XML (xml)src/views/home.js
export default function home({ userName, message }) {
return `
<section>
<h1>Welcome, ${userName || "Guest"}</h1>
<p>${message || "This is the home page of your mini SPA."}</p>
</section>
`;
}Code language: JavaScript (javascript)src/views/products.js
export default function products({ products = [] }) {
const items = products.map(
({ name, price }) => `<li>${name} - $${price}</li>`
).join("");
return `
<section>
<h1>Products</h1>
<ul>${items || "<li>No products available</li>"}</ul>
</section>
`;
}Code language: JavaScript (javascript)src/views/login.js
export default function login() {
return `
<section>
<h1>Login</h1>
<form data-action="login">
<label>
Username:
<input name="username" type="text" required />
</label>
<label>
Password:
<input name="password" type="password" required />
</label>
<button type="submit">Login</button>
</form>
</section>
`;
}Code language: HTML, XML (xml)Entry Point (src/index.js)
This file wires everything together:
- Sets layout at wildcard route (
/*) - Defines routes (
/,/products,/login,/logout) - Renders views
- Attaches view handlers
import { page, render, requireAuth, layout, login, logout } from "./spa.js";
import layoutTemplate from "./views/layout.js";
import homeTemplate from "./views/home.js";
import productsTemplate from "./views/products.js";
import loginTemplate from "./views/login.js";
import "./style.scss";
page("/*", async (ctx, next) => {
await layout(layoutTemplate);
next();
});
page("/", async () => {
await render(homeTemplate, "/api/home");
});
page("/products", requireAuth, async () => {
await render(productsTemplate, "/api/products");
});
page("/login", async () => {
await render(loginTemplate);
const form = document.querySelector("form[data-action='login']");
form.addEventListener("submit", e => {
e.preventDefault();
const formData = new FormData(form);
const username = formData.get("username");
const password = formData.get("password");
if (username === "admin" && password === "secret") {
login(username); // call SPA core login
page.redirect("/products"); // redirect after login
} else {
alert("Invalid credentials");
}
});
});
page("/logout", async () => {
logout();
page.redirect("/");
});
page("*", async () => {
await render(() => "<h2>404 - Page Not Found</h2>");
});
page();Code language: JavaScript (javascript)Shell (src/index.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Lean SPA Framework</title>
</head>
<body>
<div id="app"></div>
</body>
</html>Code language: HTML, XML (xml)Styling (src/style.scss)
// Override Bootstrap variables before import
$primary: #ff6600;
// Import Bootstrap SCSS
@import "bootstrap/scss/bootstrap";
// Custom styles
body {
font-family: sans-serif;
padding: 2rem;
}
nav {
background-color: $primary;
padding: 1rem;
}
nav a {
color: white;
margin-right: 1rem;
text-decoration: none;
}
.error {
color: red;
padding: 1rem;
}Code language: PHP (php)Key Takeaways
- API data is cached — once fetched, it’s stored and reused for subsequent renders.
- Authentication guard is handled by
page.js. - Login/logout is in the SPA core.
- Attaches view handlers in the entry point (
index.js). - State info such as login information is automatically injected and made available to templates alongside any API data.
- Lean MVP pattern keeps routes and templates simple but extensible.
Styling with style.scss
All custom styling for your SPA lives in the src/style.scss file. This file is deliberately minimal and acts as the single source of truth for your application’s look and feel.
- Bootstrap overrides
Before importing Bootstrap’s SCSS, you can redefine its variables to change the default theme. For example:
$primary: #ff6600; // override Bootstrap's primary color
@import "bootstrap/scss/bootstrap";Code language: PHP (php)- Custom rules
After importing Bootstrap, you add your own selectors and rules. These can be global (likebodyandnav) or scoped to specific components.
body {
font-family: sans-serif;
padding: 2rem;
}
nav {
background-color: $primary;
padding: 1rem;
}
nav a {
color: white;
margin-right: 1rem;
text-decoration: none;
}
.error {
color: red;
padding: 1rem;
}Code language: CSS (css)This approach keeps styling lean and centralized, while still allowing you to take advantage of Bootstrap’s responsive grid and utility classes.
How Webpack Builds the CSS
Webpack automatically processes your SCSS because you import ./style.scss in index.js. The loaders defined in webpack.common.js handle the pipeline:
sass-loadercompiles SCSS → CSS.css-loaderresolves imports and URLs.style-loaderinjects styles into the DOM during development.
In production, you can extend this with plugins like MiniCssExtractPlugin to output a separate .css file, but even with the current setup, Webpack bundles your styles into the final build without needing manual <link> tags.
Deployment with Webpack Production Mode
Deployment is handled via the npm scripts defined in package.json:
- Development:
npm startRuns webpack-dev-server with hot reloading, serving from memory.
- Production build:
npm run buildExecutes webpack --config webpack.prod.js in production mode. This applies optimizations like minification, tree‑shaking, and code splitting.
- Output location:
The optimized assets are placed in thedist/folder (as defined inwebpack.common.js). Insidedist/, you’ll find:index.html(with scripts/styles injected byHtmlWebpackPlugin)main.js(your bundled JavaScript)- Any processed images or assets
This dist/ folder is what you deploy to your hosting provider (Netlify, Vercel, GitHub Pages, or a traditional server). It’s self‑contained and ready to serve.
Final Wrap‑Up
You now have:
- A lean SPA framework with routing, state, and views.
- A styling pipeline (
style.scss) that overrides Bootstrap and adds custom rules. - Webpack automatically compiling SCSS → CSS and bundling everything.
- A production deployment flow that outputs optimized assets into
dist/vianpm run build.
This completes the tutorial: you can build, style, and deploy a reproducible, ergonomic SPA with minimal overhead.
