A simple client-side SPA (Single Page Application) framework using vanilla TypeScript.
Great for learning, experimenting, or building small-scale apps without heavy dependencies.
- Simple client-side routing with automatic link interception
- Route parameters support (e.g.,
/user/:id,/post/:slug) - Dynamic template rendering
- Dual templating syntax: Handlebars-style (
{{ }}) and XML-style (<text />,<each>,<if>) - Reactive state management with Store (global & local)
- Automatic cleanup of event listeners and subscriptions
- Tailwind CSS v4 integration with Vite plugin
- Dark mode support
- Progressive Web App (PWA) support with offline capabilities
- Optimized performance with code splitting and caching strategies
- Service Worker with automatic updates
- Written in clean TypeScript
git clone https://github.com/Abdulkader-Safi/vanilla_ts_spa.git
# change directory to the project
cd vanilla_ts_spanpm install
# or using Bun
bun installThis project uses Vite for local development and Tailwind CSS v4 for styling.
npm run dev
# or using Bun
bun run devGo to: http://localhost:5173
This project uses Tailwind CSS v4 (beta) for modern, utility-first styling.
Tailwind is already configured and ready to use. The setup includes:
@tailwindcss/viteplugin invite.config.ts- Tailwind imported in
src/style/style.css - Zero configuration needed - templates are auto-detected
Simply add Tailwind utility classes to your HTML templates:
<div class="bg-blue-500 text-white p-4 rounded-lg shadow-md hover:bg-blue-700">
<h1 class="text-2xl font-bold">Hello World</h1>
</div>The project supports dark mode out of the box. Use dark: prefix for dark mode styles:
<div class="bg-white dark:bg-slate-800 text-slate-900 dark:text-white">
Content adapts to dark mode
</div>This project is configured as a Progressive Web App, allowing it to be installed on mobile devices and work offline.
- Installable: Add to home screen on mobile devices
- Offline Support: Works without internet connection using Service Worker
- Auto-Update: Service worker automatically updates in the background
- Optimized Caching: Smart caching strategies for assets, images, and API calls
- Fast Loading: Cached resources load instantly on repeat visits
The project includes several performance optimizations:
- Code Splitting: Each route can be lazy-loaded, reducing initial bundle size
- Terser Minification: Production builds are optimized with console.log removal
- Manual Chunking: Vendor code separated for better caching
- CSS Code Splitting: Styles loaded on-demand
- Service Worker Caching:
- Images cached for 30 days (CacheFirst)
- JS/CSS cached with StaleWhileRevalidate (7 days)
- Fonts cached for 1 year
- Maximum cache size: 3MB per file
For optimal mobile performance with many pages (50+):
- Use Lazy Loading:
// Split routes into separate chunks
router.addRoute("/page1", async () =>
(await import("./pages/Page1")).default()
);
router.addRoute("/page2", async () =>
(await import("./pages/Page2")).default()
);-
Add Route Prefetching for better UX on likely navigation paths
-
Optimize Images:
- Use WebP format
- Add lazy loading:
<img loading="lazy"> - Compress images before deployment
-
Template Caching: The View function automatically caches loaded templates
To add PWA icons, place these files in the public/ folder:
pwa-64x64.pngpwa-192x192.pngpwa-512x512.pngmaskable-icon-512x512.pngapple-touch-icon.pngmask-icon.svgfavicon.ico
You can generate these using PWA Asset Generator or RealFaviconGenerator.
You define routes using the Router class. Each route maps a path to an async component.
The router automatically intercepts all internal links (starting with /) and handles navigation without page reloads.
import { Router } from "./Core/Router";
import { View } from "./Core/View";
import { Store } from "./Core/Store";
const root = document.querySelector<HTMLDivElement>("#app")!;
const router = new Router(root);
// Create a global store (persists across navigation)
const counterStore = new Store(0);
router.addRoute("/", async (params) => {
const view = await View("home.html", { name: "Safi" });
// Set up state management
const countElement = view.querySelector("#count");
const incrementBtn = view.querySelector("#increment");
// Subscribe to state changes
const unsubscribe = counterStore.subscribe((value) => {
countElement.innerText = value.toString();
});
// Add event listener
const handleIncrement = () => counterStore.set(counterStore.get() + 1);
incrementBtn.addEventListener("click", handleIncrement);
// Clean up when navigating away
view.addEventListener("cleanup", () => {
unsubscribe();
incrementBtn.removeEventListener("click", handleIncrement);
});
return view;
});
router.start();The router supports dynamic route parameters using the :paramName syntax. Route components receive a params object containing the extracted values.
// Define a route with parameters
router.addRoute("/user/:id", async (params) => {
// params.id contains the value from the URL
return View("user.html", {
userId: params.id,
title: `User Profile - ${params.id}`,
});
});
// Multiple parameters
router.addRoute("/post/:category/:slug", async (params) => {
return View("post.html", {
category: params.category,
slug: params.slug,
});
});Example URLs:
/user/123βparams = { id: "123" }/post/tech/my-articleβparams = { category: "tech", slug: "my-article" }
Use the View(templatePath, context) function to load an HTML template and inject dynamic data.
The templatePath parameter should point to an HTML file located in the src/view folder.
Templating Syntax:
The framework supports two templating syntaxes that can be used interchangeably or mixed together:
Handlebars-style Syntax:
- Variables:
{{ variable }} - Conditionals:
{{#if condition}} ... {{else if condition}} ... {{else}} ... {{/if}} - Loops:
{{#each list}} ... {{/each}}
XML-style Syntax:
- Variables:
<text data="variable" /> - Conditionals:
<if data="condition"> ... <elseif data="condition" /> ... <else /> ... </if> - Loops:
<each data="list"> ... </each>
Example:
View("about.html", {
name: "Safi",
users: [{ name: "Alice" }, { name: "Bob" }],
});Use the Store class for reactive state management. The store notifies subscribers when the state changes.
import { Store } from "./Core/Store";
// Create a store with initial state
const counterStore = new Store(0);
// Subscribe to state changes
const unsubscribe = counterStore.subscribe((value) => {
console.log("New value:", value);
});
// Update state
counterStore.set(counterStore.get() + 1); // or use updater function
counterStore.set((prev) => prev + 1);
// Unsubscribe when done
unsubscribe();Key Features:
- Generic
Store<T>for type safety - Subscribe/unsubscribe pattern
- Supports updater functions
- No page refresh - only subscribed components update
Global vs Local State:
- Global Store (outside route): State persists across navigation
- Local Store (inside route): State resets when navigating away
// Global - persists across pages
const globalCounter = new Store(0);
router.addRoute("/", async () => {
// Local - resets on navigation
const localCounter = new Store(0);
// ...
});Automatic Cleanup:
The router dispatches a cleanup event when navigating away. Use it to prevent memory leaks:
view.addEventListener("cleanup", () => {
unsubscribe();
element.removeEventListener("click", handler);
});router.addRoute("/about", async (params) => {
return View("about.html", {
name: "Safi",
users: [{ name: "John" }, { name: "Jane" }],
user: {
isAdmin: true,
},
});
});And in src/view/about.html (Handlebars-style):
<h1>Hello, {{name}}</h1>
{{#if user.isAdmin}}
<p>You are an admin</p>
{{else}}
<p>You are a guest</p>
{{/if}}
<ul>
{{#each users}}
<li>{{name}}</li>
{{/each}}
</ul>Or using XML-style syntax:
<h1>Hello, <text data="name" /></h1>
<if data="user.isAdmin">
<p>You are an admin</p>
<else />
<p>You are a guest</p>
</if>
<ul>
<each data="users">
<li><text data="name" /></li>
</each>
</ul>Or mix both styles:
<h1>Hello, {{name}}</h1>
<each data="users">
<li>{{ name }}</li>
</each>No β this is a client-side rendered SPA.
That means search engines might not index your content effectively.
You'd need to add:
- Server-Side Rendering (SSR), or
- Static Site Generation (pre-rendered HTML)
βββ src/
β βββ Core/
β β βββ Router.ts # Custom router class
β β βββ View.ts # Template engine
β β βββ Store.ts # Reactive state management
β βββ view/
β β βββ home.html # Home page template
β β βββ about.html # About page template
β βββ style/
β β βββ style.css # Tailwind CSS imports
β βββ main.ts # App entry point
βββ public/ # Static assets
βββ vite.config.ts # Vite + Tailwind configuration
βββ index.html # Mount point- Learning routing and templating
- Building mini apps and demos
- Understanding SPA basics with TypeScript
- Learning modern CSS with Tailwind CSS v4
- Practicing reactive state management patterns
- TypeScript - Type-safe JavaScript
- Vite - Fast build tool and dev server
- Tailwind CSS v4 - Utility-first CSS framework
- Bun - Fast JavaScript runtime and package manager
- add Server-Side Rendering (SSR)
- Support Router Parameters (/user/
:id) - Data fetching layer (like useEffect)
- Middleware / Guards
- Global / Local State Management
- Automatic cleanup system for subscriptions and event listeners
- Link interception for SPA navigation
- Tailwind CSS v4 integration
- Progressive Web App (PWA) support
- Service Worker with offline capabilities
- Performance optimizations (code splitting, caching)
- i18n Internationalization
- Virtual DOM for better performance