Single Page Application (SPA) and Client-Side Routing (CSR) with Vanilla ASP.NET Web Forms

This is part of the series of introducing Vanilla ASP.NET Web Forms Architecture. Read more about this at the main menu: Main Article: [Introducing Vanilla ASP.NET Web Forms Architecture]

Doing Single Page Application (SPA) and Client-Side Routing (CSR) in ASP.NET Web Forms

SPA-CSR is a software engineering design pattern.

Single Page Application is a web page with a single page initialization only. Just a single page in a nutshell. It load all it’s contents dynamically, including additional JavaScript and CSS.

It proposes a software architecture idea that a web page will never need a full page load, only partial load.

A simple example of partial load and DOM manipulation

// before
<div id='main_content'></div>


<script>
async function GetContent() {
    // fetchapi
    let response = await fetch("/api?action=sayhello");

    let output = await response.text();

    // output = "Hello World!";

    document.querySelector("#main_content").innerText = output;
}
</script>


// after
<div id='main_content'>Hello World!</div>

Then, bring the same idea to full scale to the extent that loading everything (contents, css, js) dynamically (as explained above).

Example of dynamically load CSS:

// container of empty css
<link id="linkDefaultStyle" rel="stylesheet" type="text/css" />

<script>
    // Get a reference to the <link> element
    const linkElement = document.getElementById('linkDefaultStyle');
    
    // Set the new CSS file path
    linkElement.href = '/styles/dark.css';
    linkElement.href = '/styles/light.css';
</script>

Example of dynamically load JavaScript

<script id="scriptDefault" />

// Get a reference to the <script> element
const scriptElement = document.getElementById('scriptDefault');

// Set the new JavaScript file path
scriptElement.src = '/scripts/new-functionality.js';

Since all contents are being loaded with no real page navigation, it might raise another minor user experience problem. From the end user experience perspective, the user expects a real page navigation and a browsing history that can go [back] and [forward].

Therefore SPA architecture suggests to simulate a browsing history experience. It overrides the original page navigation events such as “click”, “back”, and “forward”, with partial load (fetchapi/ajax) and DOM manipulation, then creates an artificially browsing history and save the required state along with the page history.

The JavaScript to override “click”, “back”, “forward” and creating artificial browsing history:

// don't intercept these links
// external links
<a href="https://adriancs.com">adriancs.com</a>
// javascript actions
<a href="#" onclick="doSomething(); return false">Do Something</a>

// now, intercept these links
// marks these links with an attribute of [data-route] for navigation override and special handling
<a data-route href="/home">home</a>
<a data-route href="/about">about</a>
<a data-route href="/product-list">products</a>


// Example of some global state
let userInfo = { id = 0, username = "" };
let product = {
    id: 1,
    qty: 2,
    options: {
        style: 3,
        color: 4
    }
};

// -----------------------------------
// The Primary Core Component in SPA
// -----------------------------------
// Intercept all clicks of the entire document
document.addEventListener('click', (e) => {
    
      // check if the clicked element has the attribute of [data-route]
      if (e.target.matches('[data-route]')) {
      
          // override, cancel/stop the click behaviour
          e.preventDefault();
          
          // get the target destination URL
          const url = e.target.getAttribute('href');
  
          // Uses the HTML5 History API to manipulate the browser's session history
          // This adds an artificial browsing history, 
          // and saving a custom "state", a javascript object/string etc..
          // And change the URL address bar without a full page reload
          window.history.pushState(product, 'Bicylcle - Product Info', url);
          
          // apply custom logic to handle the link clicks
          // for example fetchapi or ajax and modify the HTML page content
          handleNavigation(url);
        }
    }
});


// -----------------------------------
// The Second Core Component/Logic in SPA
// -----------------------------------
// Intercepting  browser back/forward buttons events
window.addEventListener('popstate', (e) => {

    // stage 1: restoring the "state"
    
    if (window.location.pathname.startsWith('/product/')) {
        // restore the state
        product = e.state;
    }
    else if (window.location.pathname.startsWith('/user/')) {
        // restore the state
        userInfo = e.state;
    }
    
    // stage 2: override the navigation
    
    let url = window.location.pathname + window.location.search;
    
    handleNavigation(url);
});


// ---------------------------------------------------
// The Third Core Component - Custom Navigation Override & Handling
// ---------------------------------------------------
// perform custom logic to handle data loading, dynamically edit html page content
function handleNavigation(url) {

    if (url.startsWith("/about/")) {
        // custom handling logic
        // fetchapi > fetch data
        // render html
        // clean up (if require)
    }
    else if (url.startsWith("/product/")) {
        // custom handling logic
        // fetchapi > fetch data
        // render html
        // clean up (if require)
    }
    else if (url.startsWith('/login')) {
        // ...
    }
    else if (url.startsWith('/edit-product/')) {
        // ...
    }
    else if (url.startsWith('/home/')) {
        // ...
    }
    else if (url.startsWith('/contact/')) {
        // ...
    }
    else {
        // ...    
    }
}

Example of Partial Load and Dynamically Modifying the HTML Page:

function handleNavigation(url) {
    if (url.startsWith("/about/")) {
        handleAboutPage();
    }
    // ... other route handlers
}

// Custom function to handle about page
async function handleAboutPage() {

    // Stage 1: Fetch data
    const response = await fetch('/api?q=about');
    
    // Stage 2: Get the object data
    const jsonObject = await response.json();
    
    // Stage 3: Render the HTML
    
    const html = `
    
        <h1>${jsonObject.Title}</h1>
        <p>Author: ${jsonObject.Author}, Last update: ${jsonObject.LastUpdate}</p>
        <div class='main-content'>${jsonObject.MainArticle}</div>
    `;
    
    // Stage 4: Show/Hide container:
    
    // hide other pages:
    document.querySelector('.page-contact').style.display = "none";
    document.querySelector('.page-login').style.display = "none";
    document.querySelector('.page-cart').style.display = "none";
    
    // show the main page:
    document.querySelector('.page-main').style.display = "block";
    
    // Stage 5: Partial load the content
    document.querySelector('.page-main').innerHTML = html
    
    // Stage 6: Clean up (if required)
    // Remove old timers, event listeners, etc.
}

DOM Objects’ Memory Clean Up

Browsers are generally very efficient at garbage collecting and managing the DOM. When an element’s attributes (like href or src) are updated, this is simply changing a property of an existing element; it’s not creating a new, lingering element that needs to be removed.

Basically, manual cleanup is only required when a new element is created and is no longer being used, or when new event listeners are attached and need to be removed to prevent memory leaks.

Example of Cleaning Up:

// A simple object to hold cleanup functions for each route
const cleanupCallbacks = {};

// Helper function to run the cleanup
function runCleanup() {

  const currentPath = window.location.pathname;

  // Check if a cleanup function exists for the current path and run it
  if (cleanupCallbacks[currentPath]) {

    cleanupCallbacks[currentPath]();
    
    // Remove the callback after running it to avoid duplicate calls
    delete cleanupCallbacks[currentPath];
  }
}

// ---------------------------------------------------
// The Third Core Component - Custom Navigation Override & Handling
// ---------------------------------------------------
function handleNavigation(url) {

    // Run cleanup for the previous route BEFORE loading the new one
    runCleanup();

    if (url.startsWith("/product/")) {
        // custom handling logic
        // ... fetch data for product view ...
        // ... render html ...

        // Register a cleanup function for THIS route
        cleanupCallbacks[url] = () => {
            // Example cleanup: remove a dynamically created slider and its listeners
            const slider = document.getElementById('product-slider');
            if (slider) {
                // Assuming you have a destroy method for the slider
                slider.destroy(); 
                slider.remove();
            }
        };
    } 
    else if (window.location.pathname.startsWith('/login')) {
        // custom handling logic
        // ... fetch data for login view ...
        // ... render html ...

        // Register a cleanup function for THIS route
        cleanupCallbacks[url] = () => {
            // Example cleanup: remove an event listener from a form
            const loginForm = document.getElementById('login-form');
            if (loginForm) {
                // You'd need a reference to the specific handler function
                loginForm.removeEventListener('submit', loginFormHandler);
            }
        };
    }
    // ... add more routes with their specific cleanup logic ...
}

Summary

These mechanisms work together to create the illusion of traditional page navigation. While there are no real “routes” in the server-side sense, users experience seamless page “transitions”. Hence the web development community calls this “Client-Side Routing.”

The essence of Single Page Applications lies in pushing partial loads and DOM manipulation to their limits: dynamically altering HTML structures, fetching content via AJAX/Fetch API, managing application state, maintaining artificial browsing history, and handling cleanup—all without ever leaving the original page.

SPA Use Case Scenario

This is especially useful in IoT where the devices have small RAM constrains that they exceptionally appreciate light weight API Endpoint style to communicate with the Frontend. Mobile app is another, it super like the idea of light weight data transmission with the backend server that will save a lot of data bandwidth.

Some Famous SPA Websites: Gmail, Facebook, X.com, Youtube, Netflix, Spotify, Discoard, Whatsapp, VS Code Online

Demo

Let’s see some demo in action shall we? We have already touched on the Frontend side of doing SPA, but we haven’t touched anything at the server backend API side, so, let’s us begin with the server side.

Below presents the backend API handling in Vanilla ASP.NET Web Forms, but the core idea can be easily migrated to other frameworks, such as MVC, .NET Core, etc.

C# Web Forms Backend API

Let’s design the API URL patterns:

/pages/api/apiSPA.aspx

and route the page to

/apiSPA

so the URL of the API endpoint will become something like this:

Style 1: RESTful Style / MVC alike style
--------------------------------
/apiSPA/article-list
/apiSPA/save-article/123
/apiSPA/edit-article/123
/apiSPA/login
/apiSPA/logout
/apiSPA/user/123

Style 2: Query String
--------------------------------
/apiSPA?q=article-list
/apiSPA?q=save-article&id=123
/apiSPA?q=edit-article&id=123
/apiSPA?q=login
/apiSPA?q=logout

Style 3: Form Post - No Query String
--------------------------------
/apiSPA  <-- Just this, everything set in Form Data Post

Style 4: JSON Body Post - No Query String
--------------------------------
/apiSPA  <-- Just this, everything set in JSON Body Post

Let’s design an API Endpoint that supports all 4 styles.

In order to make the web application compatible with both URL styles, we need route the URL pattern to the page. Add a Global Application Class and map the route pattern:

using System.Web.Routing;

public class Global : System.Web.HttpApplication
{
    protected void Application_Start(object sender, EventArgs e)
    {
        // route the main api page
        RouteTable.Routes.MapPageRoute("api1", "apiSPA", "~/apiSPA.aspx");
        
        // route all other URL patterns
        RouteTable.Routes.MapPageRoute("api2", "apiSPA/{action}", "~/apiSPA.aspx");
        RouteTable.Routes.MapPageRoute("api3", "apiSPA/{action}/", "~/apiSPA.aspx");
        RouteTable.Routes.MapPageRoute("api4", "apiSPA/{action}/{id}", "~/apiSPA.aspx");
        RouteTable.Routes.MapPageRoute("api5", "apiSPA/{action}/{id}/", "~/apiSPA.aspx");

        // deliver the same skeleton web layout regardless of what page it is
        // all sub-content will be updated via api
        RouteTable.Routes.MapPageRoute("all", "{*all}", "~/Default.aspx");
    }
}

Next, create a blank Web Forms which looks like this:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiSPA.aspx.cs" Inherits="myweb.apiSPA" %>

<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
</head>
<body>
    <form id="form1" runat="server">
        <div>
        </div>
    </form>
</body>
</html>

Delete everything and leave only the first line:

<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="apiSPA.aspx.cs" Inherits="myweb.apiSPA" %>

The C# code behind:

public partial class apiSPA: System.Web.UI.Page
{
    string action = "";
    int id = 0;

    protected void Page_Load(object sender, EventArgs e)
    {
        // Style 1: RESTful, MVC alike URLs
        // url = /apiSPA/{action}/{id}
        action = RouteData.Values["action"] + "";
        int.TryParse(RouteData.Values["id"] + "", out id);
    
        // Style 2 & 3: Query string or Form Data Post
        // url = /apiSPA?q={action]&id={data}
        if (action == "")
            action = Request["q"] + "";
        
        if (id == 0 && Request["id"] != null)
            int.TryParse(Request["id"] + "", out id);
            
        // Style 4: JSON body
        string json = "";
        using (StreamReader reader = new StreamReader(Request.InputStream))
        {
            json = reader.ReadToEnd();
        }
        var requestObject = JsonSerializer.Deserialize<RequestObject>(json);
        action = requestObject.action;
        id = requestObject.id;

        // ===============================

        action = action.ToLower();

        // Actions require login authentication
        switch (action)
        {
            case "save-article":
            case "edit-article":

                // Require user login authentication
                if (Database.CurrentUser == null)
                {
                    // HTTP Error 401 Unauthorized
                    Response.StatusCode = 401;
                    return;
                }
                break;

            case "get-user-list":
            case "edit-user":
            case "save-user":

                // Require user login and admin privilege
                if (Database.CurrentUser == null || !Database.CurrentUser.IsAdmin)
                {
                    // HTTP Error 401 Unauthorized
                    Response.StatusCode = 401;
                    return;
                }
                break;
        }

        switch (action)
        {
            case "article-list":
                GetArticleList();
                break;
            case "edit-article":
                EditArticle();
                break;
            case "save-article":
                SaveArticle();
                break;
            case "login":
                PerformLogin();
                break;
            case "logout":
                PerformLogout();
                break;
            case "get-user-list":
                GetUserList();
                break;
            case "edit-user":
                EditUser();
                break;
            case "save-user":
                SaveUser();
                break;
            default:
                GetPage(action);
                break;
        }
    }

    private void GetPage(string slug)
    {
        Article article = Database.GetPage(slug);

        if (article == null)
        {
            Response.StatusCode = 404;
            Response.Write("Page not found");
            return;
        }

        string json = JsonSerializer.Serialize(article);

        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void GetArticleList()
    {
        List<Article> articles = Database.GetArticleList();

        string json = JsonSerializer.Serialize(articles);
        
        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void EditArticle()
    {
        // Form Data or Query String

        int.TryParse(Request["id"] + "", out int id);

        Article article = Database.GetArticle(id);

        string json = JsonSerializer.Serialize(article);

        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void SaveArticle()
    {
        // Form Data

        int.TryParse(Request["id"] + "", out int id);
        int.TryParse(Request["status"] + "", out int status);

        Article a = new Article()
        {
            ID = id,
            Title = Request["title"],
            Slug = Request["slug"],
            GithubMarkup = Request["content"],
            Status = status
        };

        a.RenderedHtml = Database.RenderHTML(a.GithubMarkup);
        a.DateLastEdit = DateTime.Now;

        if (a.ID == 0)
        {
            a.DateCreated = DateTime.Now;
        }

        Database.SaveArticle(a);
    }

    private void PerformLogin()
    {
        // Form Data
        string username = Request["username"] + "";
        string password = Request["password"] + "";

        User user = Database.VerifyLogin(username, password);

        var result = new
        {
            Success = user != null,
            User = user
        };

        string json = JsonSerializer.Serialize(result);

        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void PerformLogout()
    {
        Session.Clear();
        Session.Abandon();
    }

    private void GetUserList()
    {
        // Form Data
        int.TryParse(Request["status"] + "", out int status);

        // Database - get users
        List<User> users = Database.GetUsers(status);

        string json = JsonSerializer.Serialize(users);

        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void EditUser()
    {
        // Form Data or Query String
        int.TryParse(Request["id"] + "", out int userid);

        // Database - get user
        User user = Database.GetUser(userid);

        string json = JsonSerializer.Serialize(user);

        Response.ContentType = "application/json";
        Response.Write(json);
    }

    private void SaveUser()
    {
        // Get the JSON string
        string json = "";
        using (var reader = new System.IO.StreamReader(Request.InputStream))
        {
            json = reader.ReadToEnd();
        }

        // JSON
        User user = JsonSerializer.Deserialize<User>(json);

        Database.SaveUser(user);
    }
}

public class Article
{
    public int ID { get; set; }
    public string Title { get; set; }
    public string Slug { get; set; }
    public string GithubMarkup { get; set; }
    public string RenderedHtml { get; set; }
    public int Status { get; set; }
    public DateTime DateCreated { get; set; }
    public DateTime DateLastEdit { get; set; }
}

public class User
{
    public int ID { get; set; }
    public string Username { get; set; }
    public string FullName { get; set; }
    public string Pwd { get; set; }
    public int Status { get; set; }
    public bool IsAdmin { get; set; }
    public string Email { get; set; }
}

public class RequestObject
{
    public string action { get; set; }
    public int id { get; set; }
}

The HTML (Basic Web Layout, The Skeleton of Structure)

Now, based on available API Endpoint actions, we can write a SAP page to on it:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>SPA-CSR Web Forms Demo</title>
    <link id='dynamic_css' rel='stylesheet' />
    <style>

        // the css will be shown later at the end of this article

    </style>
</head>
<body>
    <!-- Navigation -->
    <nav class="navbar">
        <div class="nav-container">
            <div class="nav-brand">My Blog Platform</div>
            <ul class="nav-links">
                <li><a href="/" class="nav-link" data-route>Home</a></li>
                <li><a href="/article-list" class="nav-link" data-route>Articles</a></li>
                <li><a href="/about" class="nav-link" data-route>About</a></li>
                <li><a href="/contact" class="nav-link" data-route>Contact</a></li>
                <li id="nav-admin" style="display: none;"><a href="/admin" class="nav-link" data-route>Admin</a></li>
                <li id="nav-login"><a href="/login" class="nav-link" data-route>Login</a></li>
                <li id="nav-logout" style="display: none;"><a href="/logout" class="nav-link" data-route>Logout</a></li>
            </ul>
        </div>
    </nav>

    <!-- Main Content Container -->
    <div class="main-container">
        <!-- Current Route Display -->
        <div class="route-display">
            <strong>Current Route:</strong> <span id="current-route-display">/</span>
        </div>

        <!-- Home Page -->
        <div id="page-home" class="page active">
            <h1>Welcome to My Blog Platform</h1>
            <p>This is a demonstration of SPA (Single Page Application) with CSR (Client-Side Routing) using ASP.NET Web Forms as the backend API.</p>
            <p>Navigate through the site using the links above - notice how the URL changes but the page never refreshes!</p>
            <div id="recent-articles">
                <!-- Recent articles will be loaded here -->
            </div>
        </div>

        <!-- Article List Page -->
        <div id="page-article-list" class="page">
            <h1>Articles</h1>
            <div class="loading" id="articles-loading">Loading articles...</div>
            <div id="articles-container" style="display: none;">
                <table class="table">
                    <thead>
                        <tr>
                            <th>Title</th>
                            <th>Status</th>
                            <th>Created</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    <tbody id="articles-list">
                        <!-- Articles will be populated here -->
                    </tbody>
                </table>
            </div>
        </div>

        <!-- Edit Article Page -->
        <div id="page-edit-article" class="page">
            <h1 id="article-form-title">Create New Article</h1>
            <form id="article-form">
                <input type="hidden" id="article-id" value="0">

                <div class="form-group">
                    <label for="article-title">Title:</label>
                    <input type="text" id="article-title" class="form-control" required>
                </div>

                <div class="form-group">
                    <label for="article-slug">Slug:</label>
                    <input type="text" id="article-slug" class="form-control" required>
                </div>

                <div class="form-group">
                    <label for="article-content">Content (Markdown):</label>
                    <textarea id="article-content" class="form-control" required></textarea>
                </div>

                <div class="form-group">
                    <label for="article-status">Status:</label>
                    <select id="article-status" class="form-control">
                        <option value="0">Draft</option>
                        <option value="1">Published</option>
                    </select>
                </div>

                <button type="submit" class="btn">Save Article</button>
                <a href="/article-list" class="btn btn-danger" data-route>Cancel</a>
            </form>
        </div>

        <!-- Login Page -->
        <div id="page-login" class="page">
            <h1>Login</h1>
            <form id="login-form">
                <div class="form-group">
                    <label for="username">Username:</label>
                    <input type="text" id="username" class="form-control" required>
                </div>

                <div class="form-group">
                    <label for="password">Password:</label>
                    <input type="password" id="password" class="form-control" required>
                </div>

                <button type="submit" class="btn">Login</button>
            </form>
        </div>

        <!-- Admin Page -->
        <div id="page-admin" class="page">
            <h1>Admin Dashboard</h1>
            <p>Welcome to the admin area!</p>

            <h2>Users</h2>
            <div class="loading" id="users-loading">Loading users...</div>
            <div id="users-container" style="display: none;">
                <table class="table">
                    <thead>
                        <tr>
                            <th>Username</th>
                            <th>Full Name</th>
                            <th>Email</th>
                            <th>Status</th>
                            <th>Actions</th>
                        </tr>
                    </thead>
                    <tbody id="users-list">
                        <!-- Users will be populated here -->
                    </tbody>
                </table>
            </div>
        </div>

        <!-- Dynamic Page Content -->
        <div id="page-dynamic" class="page">
            <h1 id="dynamic-title">Page Title</h1>
            <div id="dynamic-content">
                <p>Page content will be loaded here... </p>
            </div>
        </div>

        <!-- 404 Page -->
        <div id="page-not-found" class="page">
            <h1>404 - Page Not Found</h1>
            <p>The page you're looking for doesn't exist.</p>
            <a href="/" class="btn" data-route>Go Home</a>
        </div>
    </div>

    <script>

        // the javascript will be shown later

    </script>
</body>
</html>

The JavaScript:

// API endpoint - your Web Forms page
const API_ENDPOINT = '/apiSPA';

// Global state
let currentUser = null;
let currentRoute = '';

// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
    initializeRouter();
});

// Router Functions
function initializeRouter() {
    // Listen for browser back/forward buttons
    window.addEventListener('popstate', (e) => {
        handleRoute(window.location.pathname + window.location.search);
    });

    // Handle link clicks
    document.addEventListener('click', (e) => {
        if (e.target.matches('[data-route]')) {
            e.preventDefault();
            const href = e.target.getAttribute('href');
            navigate(href);
        }
    });

    // Handle form submissions
    document.getElementById('login-form').addEventListener('submit', handleLogin);
    document.getElementById('article-form').addEventListener('submit', handleSaveArticle);

    // Handle initial page load
    handleRoute(window.location.pathname + window.location.search);
}

// Navigate to a new route
function navigate(path) {
    // Update browser URL without page reload
    window.history.pushState({}, '', path);
    handleRoute(path);
}

// Handle route changes
function handleRoute(path) {
    currentRoute = path;
    document.getElementById('current-route-display').textContent = path;

    // Hide all pages first
    const pages = document.querySelectorAll('.page');
    pages.forEach(page => {
        page.classList.remove('active');
    });

    // Update active navigation
    updateActiveNav(path);

    // Parse the path
    const [pathname, search] = path.split('?');
    const params = parseQuery(search || '');

    // Route matching logic - matches your C# backend actions
    if (pathname === '/' || pathname === '/home') {
        showPage('home');
        loadRecentArticles();
    } else if (pathname === '/login') {
        if (currentUser) {
            navigate('/'); // Already logged in
        } else {
            showPage('login');
        }
    } else if (pathname === '/logout') {
        performLogout();
    } else if (pathname === '/article-list') {
        showPage('article-list');
        loadArticles();
    } else if (pathname === '/edit-article') {
        if (!currentUser) {
            navigate('/login');
            return;
        }
        showPage('edit-article');
        loadArticleForEdit(params.id);
    } else if (pathname === '/admin') {
        if (!currentUser || !currentUser.IsAdmin) {
            navigate('/login');
            return;
        }
        showPage('admin');
        loadUsers();
    } else if (pathname.startsWith('/')) {
        // Handle dynamic pages (slug-based content)
        showDynamicPage(pathname.slice(1)); // Remove leading slash
    } else {
        showPage('not-found');
    }
}

// Parse query string into object
function parseQuery(queryString) {
    const params = {};
    if (queryString) {
        queryString.split('&').forEach(param => {
            const [key, value] = param.split('=');
            params[decodeURIComponent(key)] = decodeURIComponent(value || '');
        });
    }
    return params;
}

// Update active navigation
function updateActiveNav(currentPath) {
    const navLinks = document.querySelectorAll('.nav-link');
    navLinks.forEach(link => {
        link.classList.remove('active');
        if (link.getAttribute('href') === currentPath) {
            link.classList.add('active');
        }
    });
}

// Show a specific page
function showPage(pageId) {
    document.getElementById(`page-${pageId}`).classList.add('active');
}

// Show dynamic page content
async function showDynamicPage(slug) {
    const page = document.getElementById('page-dynamic');
    const title = document.getElementById('dynamic-title');
    const content = document.getElementById('dynamic-content');

    try {
        const response = await fetch(`${API_ENDPOINT}?q=${slug}`);

        if (response.ok) {
            const article = await response.json();
            title.textContent = article.Title;
            content.innerHTML = article.RenderedHtml || `<p>${article.GithubMarkup}</p>`;
        } else {
            title.textContent = 'Page Not Found';
            content.innerHTML = '<p>This page could not be found.</p>';
        }
    } catch (error) {
        title.textContent = 'Error';
        content.innerHTML = `<p>Error loading page: ${error.message}</p>`;
    }

    page.classList.add('active');
}

// API Functions matching your C# backend

// Load recent articles for home page
async function loadRecentArticles() {
    try {
        const response = await fetch(`${API_ENDPOINT}?q=article-list`);
        const articles = await response.json();

        const container = document.getElementById('recent-articles');
        if (articles.length > 0) {
            container.innerHTML = '<h2>Recent Articles</h2>' + 
                articles.slice(0, 5).map(article => 
                    `<p><a href="/${article.Slug}" data-route>${article.Title}</a></p>`
                ).join('');
        }
    } catch (error) {
        console.error('Error loading recent articles:', error);
    }
}

// Load all articles
async function loadArticles() {
    const loading = document.getElementById('articles-loading');
    const container = document.getElementById('articles-container');
    const list = document.getElementById('articles-list');

    try {
        loading.style.display = 'block';
        container.style.display = 'none';

        const response = await fetch(`${API_ENDPOINT}?q=article-list`);
        const articles = await response.json();

        list.innerHTML = articles.map(article => `
            <tr>
                <td><a href="/${article.Slug}" data-route>${article.Title}</a></td>
                <td><span class="status ${article.Status === 1 ? 'status-published' : 'status-draft'}">
                    ${article.Status === 1 ? 'Published' : 'Draft'}
                </span></td>
                <td>${new Date(article.DateCreated).toLocaleDateString()}</td>
                <td>
                    ${currentUser ? `<a href="/edit-article?id=${article.ID}" class="btn" data-route>Edit</a>` : ''}
                </td>
            </tr>
        `).join('');

        loading.style.display = 'none';
        container.style.display = 'block';
    } catch (error) {
        loading.innerHTML = `Error loading articles: ${error.message}`;
    }
}

// Load article for editing
async function loadArticleForEdit(id) {
    const titleEl = document.getElementById('article-form-title');

    if (id) {
        titleEl.textContent = 'Edit Article';
        try {
            const response = await fetch(`${API_ENDPOINT}?q=edit-article&id=${id}`);
            const article = await response.json();

            document.getElementById('article-id').value = article.ID;
            document.getElementById('article-title').value = article.Title;
            document.getElementById('article-slug').value = article.Slug;
            document.getElementById('article-content').value = article.GithubMarkup;
            document.getElementById('article-status').value = article.Status;
        } catch (error) {
            alert('Error loading article: ' + error.message);
        }
    } else {
        titleEl.textContent = 'Create New Article';
        document.getElementById('article-form').reset();
        document.getElementById('article-id').value = '0';
    }
}

// Handle article save
async function handleSaveArticle(e) {
    e.preventDefault();

    const formData = new FormData();
    formData.append('id', document.getElementById('article-id').value);
    formData.append('title', document.getElementById('article-title').value);
    formData.append('slug', document.getElementById('article-slug').value);
    formData.append('content', document.getElementById('article-content').value);
    formData.append('status', document.getElementById('article-status').value);

    try {
        const response = await fetch(`${API_ENDPOINT}?q=save-article`, {
            method: 'POST',
            body: formData,
            credentials: 'include'
        });

        if (response.ok) {
            alert('Article saved successfully!');
            navigate('/article-list');
        } else {
            alert('Error saving article');
        }
    } catch (error) {
        alert('Error: ' + error.message);
    }
}

// Handle login
async function handleLogin(e) {
    e.preventDefault();

    const formData = new FormData();
    formData.append('username', document.getElementById('username').value);
    formData.append('password', document.getElementById('password').value);

    try {
        const response = await fetch(`${API_ENDPOINT}?q=login`, {
            method: 'POST',
            body: formData,
            credentials: 'include'
        });

        const result = await response.json();

        if (result.Success && result.User) {
            currentUser = result.User;
            updateUIForUser();
            navigate('/');
        } else {
            alert('Invalid username or password');
        }
    } catch (error) {
        alert('Login error: ' + error.message);
    }
}

// Perform logout
async function performLogout() {
    try {
        await fetch(`${API_ENDPOINT}?q=logout`, {
            method: 'POST',
            credentials: 'include'
        });

        currentUser = null;
        updateUIForUser();
        navigate('/');
    } catch (error) {
        console.error('Logout error:', error);
    }
}

// Load users for admin
async function loadUsers() {
    const loading = document.getElementById('users-loading');
    const container = document.getElementById('users-container');
    const list = document.getElementById('users-list');

    try {
        loading.style.display = 'block';
        container.style.display = 'none';

        const response = await fetch(`${API_ENDPOINT}?q=get-user-list`, {
            credentials: 'include'
        });

        if (response.status === 401) {
            navigate('/login');
            return;
        }

        const users = await response.json();

        list.innerHTML = users.map(user => `
            <tr>
                <td>${user.Username}</td>
                <td>${user.FullName || ''}</td>
                <td>${user.Email || ''}</td>
                <td><span class="status ${user.Status === 1 ? 'status-published' : 'status-draft'}">
                    ${user.Status === 1 ? 'Active' : 'Inactive'}
                </span></td>
                <td>
                    <button class="btn" onclick="editUser(${user.ID})">Edit</button>
                </td>
            </tr>
        `).join('');

        loading.style.display = 'none';
        container.style.display = 'block';
    } catch (error) {
        loading.innerHTML = `Error loading users: ${error.message}`;
    }
}

// Update UI based on user login status
function updateUIForUser() {
    const loginNav = document.getElementById('nav-login');
    const logoutNav = document.getElementById('nav-logout');
    const adminNav = document.getElementById('nav-admin');

    if (currentUser) {
        loginNav.style.display = 'none';
        logoutNav.style.display = 'block';
        adminNav.style.display = currentUser.IsAdmin ? 'block' : 'none';
    } else {
        loginNav.style.display = 'block';
        logoutNav.style.display = 'none';
        adminNav.style.display = 'none';
    }
}

// Utility function for editing users (placeholder)
function editUser(userId) {
    alert(`Edit user functionality would go here for user ID: ${userId}`);
}

The CSS:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    line-height: 1.6;
    color: #333;
    background: #f5f5f5;
}

/* Navigation */
.navbar {
    background: #2c3e50;
    color: white;
    padding: 1rem 0;
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 1000;
    box-shadow: 0 2px 5px rgba(0,0,0,0.1);
}

.nav-container {
    max-width: 1200px;
    margin: 0 auto;
    padding: 0 20px;
    display: flex;
    justify-content: space-between;
    align-items: center;
}

.nav-brand {
    font-size: 1.5rem;
    font-weight: bold;
}

.nav-links {
    display: flex;
    gap: 2rem;
    list-style: none;
}

.nav-link {
    color: white;
    text-decoration: none;
    padding: 0.5rem 1rem;
    border-radius: 4px;
    transition: background-color 0.3s;
}

.nav-link:hover, .nav-link.active {
    background-color: #34495e;
}

/* Main Content */
.main-container {
    margin-top: 80px;
    max-width: 1200px;
    margin-left: auto;
    margin-right: auto;
    padding: 20px;
}

.page {
    display: none;
    background: white;
    padding: 2rem;
    border-radius: 8px;
    box-shadow: 0 2px 10px rgba(0,0,0,0.1);
    margin-bottom: 2rem;
}

.page.active {
    display: block;
}

/* Forms */
.form-group {
    margin-bottom: 1rem;
}

.form-group label {
    display: block;
    margin-bottom: 0.5rem;
    font-weight: bold;
}

.form-control {
    width: 100%;
    padding: 0.75rem;
    border: 1px solid #ddd;
    border-radius: 4px;
    font-size: 1rem;
}

.form-control:focus {
    outline: none;
    border-color: #3498db;
    box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}

textarea.form-control {
    resize: vertical;
    min-height: 200px;
}

/* Buttons */
.btn {
    background: #3498db;
    color: white;
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 4px;
    cursor: pointer;
    text-decoration: none;
    display: inline-block;
    font-size: 1rem;
    margin-right: 0.5rem;
    transition: background-color 0.3s;
}

.btn:hover {
    background: #2980b9;
}

.btn-danger {
    background: #e74c3c;
}

.btn-danger:hover {
    background: #c0392b;
}

/* Tables */
.table {
    width: 100%;
    border-collapse: collapse;
    margin-top: 1rem;
}

.table th,
.table td {
    padding: 0.75rem;
    text-align: left;
    border-bottom: 1px solid #ddd;
}

.table th {
    background: #f8f9fa;
    font-weight: bold;
}

.table tr:hover {
    background: #f8f9fa;
}

/* Status indicators */
.status {
    padding: 0.25rem 0.5rem;
    border-radius: 3px;
    font-size: 0.875rem;
}

.status-published {
    background: #d4edda;
    color: #155724;
}

.status-draft {
    background: #fff3cd;
    color: #856404;
}

/* Loading state */
.loading {
    text-align: center;
    padding: 2rem;
    color: #666;
}

/* Current route display */
.route-display {
    background: #e8f4fd;
    padding: 1rem;
    border-radius: 4px;
    margin-bottom: 1rem;
    border-left: 4px solid #3498db;
}

Feature image credit: Image by Martina Bulková from Pixabay