Complete Architecture Reference for Pageless ASP.NET Web Forms in MD (Markdown) Format

Apr 7, 2026
Updated Jun 26, 2026
adriancs

This is a complete architecture reference, guideline and code convention for building modern web applications on Pageless ASP.NET Web Forms without Master Page, ASPX page files, Server Controls, ViewState, or PostBack. It covers C# string-based HTML template rendering, API endpoint patterns, Fetch API integration, file uploads, Server-Sent Events, WebSocket, and background task management.

# Complete Architecture Reference for Pageless ASP.NET Web Forms in MD (Markdown) Format

## Overview

This project uses a **Pageless Architecture** — rendering full HTML pages entirely in C# using `StringBuilder` and `Response.Write`, intercepted at the `Global.asax.cs` pipeline. No `.aspx` markup files, no master pages, no ViewState, no server controls, no page lifecycle.

Every HTTP request flows through `Global.asax.cs`, where a `switch` statement routes the path to a static handler method. The handler builds the complete HTML document — from `<!DOCTYPE html>` to `</html>` — as a C# string and writes it directly to the response stream.

---

## HTTP Errors Pass-Through

Pageless Architecture handles all requests through `Global.asax.cs` rather than physical `.aspx` files. By default, IIS intercepts responses with error status codes (404, 403, 500, etc.) and replaces them with its own error pages — overriding whatever your handler produced.

To prevent this, add the following to `web.config`:




This tells IIS to leave the application's response untouched, allowing code behind handler to remain the single source of truth for all output — including error pages.

---

## Core Principle: NO Traditional WebForms Patterns

| ❌ AVOID | ✅ USE INSTEAD |
|----------|----------------|
| `.aspx` markup files | C# string-based HTML rendering |
| Master pages (`.master`) | `PageTemplate` class (C#) |
| `<asp:Button>`, `<asp:TextBox>` | Plain HTML: `<button>`, `<input>` |
| `OnClick="btnSave_Click"` | `onclick="saveItem()"` (JS function) |
| ViewState | Client-side state, re-fetch from API |
| Postback / `IsPostBack` | Fetch API calls |
| `UpdatePanel` / AJAX Toolkit | Native `fetch()` |
| Code-behind event handlers | API endpoint actions |
| Page lifecycle (`Page_Load`, etc.) | Pipeline interception at `Global.asax.cs` |

### ⚠️ CRITICAL: Button Type Declaration
```

Default JSON Library

Newtonsoft.JSON

using Newtonsoft.Json;

Response.ContentType = "application/json";
Response.Write(JsonConvert.SerializeObject(obj));

JSON naming convention: direct matching of C# class fields or properties. Use default standard.
It can be PascalCase (PropertyName).

If the fields are primarily matching MySQL columns, use snake_case (property_name).

Never use CamelCase (propertyName).


Data Model Class Convention

Database model classes are prefixed with ob (object). The preferred pattern uses private fields in snake_case (matching MySQL column names) with public properties in PascalCase (matching C# conventions):

public class obBook
{
    int id = 0;
    string title = "";
    string author = "";
    int year = 0;
    DateTime date_created = DateTime.MinValue;
    DateTime date_modified = DateTime.MinValue;

    public int Id { get { return id; } set { id = value; } }
    public string Title { get { return title; } set { title = value; } }
    public string Author { get { return author; } set { author = value; } }
    public int Year { get { return year; } set { year = value; } }
    public DateTime DateCreated { get { return date_created; } set { date_created = value; } }
    public DateTime DateModified { get { return date_modified; } set { date_modified = value; } }
}

MySqlExpress maps MySQL columns to the private fields by matching snake_case names — no attribute mapping or naming configuration needed. C# code accesses the data through PascalCase public properties. Both layers work automatically with the same class.


Pipeline Interception — Where Pageless Rendering Begins

In Pageless Architecture, every request is intercepted in Application_BeginRequest and routed by a single switch statement. The built-in ASP.NET session module is bypassed entirely — session state is provided by a custom in-process store (see Custom Session State below) which is available immediately at BeginRequest, so there is no need to wait for AcquireRequestState or PostAcquireRequestState.

Entry PointRole
Application_StartOne-time init: connection string, DB migration, start SessionSweeper background task
Application_BeginRequestThe single routing point. Custom session is restored here via AppSession.TryRestoreFromCookie() before the route switch dispatches to a handler

Routing — Global.asax.cs

All routes are defined as a switch statement. This is the routing table for the entire application:

public class Global : System.Web.HttpApplication
{
    protected void Application_BeginRequest(object sender, EventArgs e)
    {
        string path = Request.Path.ToLower().Trim().TrimEnd('/');

        switch (path)
        {
            case "/":
            case "/home":
                RH.HomePage.HandleRequest();
                return;
            case "/api-health":
                RH.HealthApi.HandleRequest();
                return;
            case "/about":
                RH.AboutPage.HandleRequest();
                return;
            case "/books":
                RH.BookPage.HandleRequest();
                return;
            case "/bookapi":
                RH.BookPageApi.HandleRequest();
                return;
        }
    }
}

Two routes per feature — one page, one API. Add more case entries as you add features. No route configuration files, no attribute routing — you look at the switch statement and see every URL the application responds to.


Custom Session State

The built-in ASP.NET SessionStateModule is disabled. All session state is held in a single application-wide public static ConcurrentDictionary keyed by a random session id stored in an ssid cookie. This makes session data available the instant a request enters Application_BeginRequest. IIS/ASP.NET built-in Session State infrastructure is not reliable in pageless mode, do not wait for AcquireRequestState, no EnableSessionState="true" on handlers, no IRequiresSessionState marker interface.

Why custom

Built-in ASP.NET SessionCustom SessionStore
Locked behind AcquireRequestState — forces routing into PostAcquireRequestStateAvailable at BeginRequest — single routing point
Per-request reader/writer lock serializes async handlersLock-free ConcurrentDictionary
Hard to inspect, debug, or sweepPlain dictionary — enumerable, sweepable, easy to log
Tied to InProc / StateServer / SQL providerTrivially swappable for any backing store

Three-layer model

Session lives in three places, in order of speed:

  cookies (ssid + lsid)  ──►  ConcurrentDictionary (RAM)  ──►  login_sessions table (DB)
       ▲                              ▲                              │
       └──────────────────────────────┴──────────────────────────────┘
                          rehydrate on cookie hit
  1. ssid cookie — a random 48-char id, the lookup key into the in-memory dictionary. Lifespan: in-memory only (lost on app-pool recycle).
  2. SessionStore.Sessionspublic static ConcurrentDictionary<string, StateObject>. Each StateObject wraps another ConcurrentDictionary<string, object> for arbitrary per-user data plus a LastAccessUtc timestamp.
  3. login_sessions DB table — persistent "Remember Me" record holding (user_id, token, date_expiry, cookie_persistent). Referenced by an lsid cookie. Survives app restarts.

Activation — first request

// SessionStore.Current — called transparently on first read/write
public static StateObject Current
{
    get
    {
        HttpContext ctx = HttpContext.Current;
        string sid = ctx.Request.Cookies[CookieName]?.Value;

        StateObject state;
        if (string.IsNullOrEmpty(sid) || !Sessions.TryGetValue(sid, out state))
        {
            sid   = NewId();                 // 48-char random hex
            state = new StateObject();
            Sessions[sid] = state;            // <-- ConcurrentDictionary

            ctx.Response.Cookies.Set(new HttpCookie(CookieName, sid)
            {
                HttpOnly = true,
                Secure   = ctx.Request.IsSecureConnection,
                SameSite = SameSiteMode.Lax
            });
        }
        state.LastAccessUtc = DateTime.UtcNow;
        return state;
    }
}

Resuming session — DB → ConcurrentDictionary → cookie

When the in-memory entry is gone (app-pool recycle, idle sweep, cold machine) but the user still holds a valid lsid cookie, the next request walks back up the chain:

// Called once per request from Application_BeginRequest
public static void TryRestoreFromCookie()
{
    if (IsLoggedIn) return;                   // already in ConcurrentDictionary

    obUser user = UserSession.TryRestoreFromCookie();   // ── reads "lsid" cookie
                                                        // ── SELECT … FROM login_sessions
                                                        //    JOIN users WHERE token=@t AND not expired
    if (user != null)
        LoginUser = user;                     // ── writes user back into StateObject
                                              //    (lives in the ConcurrentDictionary)
}

Flow: lsid cookie → DB lookup → obUser materialized → stored into the per-session StateObject inside the ConcurrentDictionary → subsequent requests in the same app-pool lifetime hit RAM directly. The cookie's expiry is rolled forward only when remaining lifetime drops below 1/12 of the original window, keeping DB writes infrequent.

Accessing session from handlers

AppSession is a thin static facade — handlers never touch HttpContext.Session:

public static class AppSession
{
    public static obUser LoginUser
    {
        get { return SessionStore.Current?[AppSessionKeys.LoginUser] as obUser; }
        set { var s = SessionStore.Current; if (s != null) s[AppSessionKeys.LoginUser] = value; }
    }
    public static bool IsLoggedIn => LoginUser != null;
}

Inside any page or API handler:

if (!AppSession.IsLoggedIn) { Response.Redirect("/login"); return; }
obUser me = AppSession.LoginUser;

Lifecycle

  • LogoutSessionStore.Abandon() removes the entry from the dictionary and expires the ssid cookie; UserSession.DeletePersistentSession() deletes the login_sessions row and expires the lsid cookie.
  • Idle cleanupSessionSweeper runs hourly via HostingEnvironment.QueueBackgroundWorkItem, dropping StateObject entries idle for more than two hours and deleting expired login_sessions rows. Sweeping the dictionary is just a foreach over KeyValuePairs — no special API needed.
  • App-pool recycle — the dictionary is gone, but any user with a live lsid cookie is transparently restored on their next request.

Web.config — disable built-in session module

<system.web>
    <sessionState mode="Off" />
</system.web>

This removes the per-request AcquireRequestState lock entirely, which is what makes single-point routing in BeginRequest clean.


The Two-Handler Pattern

Every feature follows this pattern:

HandlerPurposeReturns
Page Handler (BookPage)Renders the full HTML pagetext/html — complete document
API Handler (BookApi)Processes Fetch API callsapplication/json or text/html fragment

One page, one API. That's the entire architecture for any feature.


File Structure Pattern

Since there are no .aspx files, all code lives in .cs class files:

/Global.asax.cs             ← routing table

/engine/
    config.cs               ← connection string, app settings
    ApiHelper.cs            ← shared response helpers
    PageTemplate.cs         ← shared HTML template (replaces master page)

/engine/
/engine/ob
/engine/Models
    obBook.cs               ← data model

/engine/RH/
    HomePage.cs             ← page handler
    AboutPage.cs            ← page handler
    BookPage.cs             ← page handler
    BookPageApi.cs          ← API handler

/css/
    site.css

/js/
    site.js
    books.js

ApiHelper — Shared Response Utilities

Every handler uses ApiHelper for response writing and termination:

using Newtonsoft.Json;
using System;
using System.Web;

namespace System
{
    public static class ApiHelper
    {
        static HttpRequest Request
        {
            get
            {
                if (HttpContext.Current == null)
                    throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
                return HttpContext.Current.Request;
            }
        }

        static HttpResponse Response
        {
            get
            {
                if (HttpContext.Current == null)
                    throw new InvalidOperationException("ApiHelper called outside of an HTTP request context.");
                return HttpContext.Current.Response;
            }
        }

        public static string GetBaseUrl()
        {
            Uri url = Request.Url;
            return $"{url.Scheme}://{url.Host}{(url.IsDefaultPort ? "" : ":" + url.Port)}";
        }

        public static void EndResponse()
        {
            // So IIS will skip handling custom errors
            Response.TrySkipIisCustomErrors = true;

            try
            {
                Response.Flush();
            }
            catch { /* client already disconnected — ignore */ }

            Response.SuppressContent = true;

            // The most reliable way in WebForms / IIS-integrated pipeline
            HttpContext.Current.ApplicationInstance.CompleteRequest();
        }

        public static void WriteJson(object obj)
        {
            Response.ContentType = "application/json";
            Response.Write(JsonConvert.SerializeObject(obj));
        }

        public static void WriteSuccess(string message = "Success")
        {
            WriteJson(new { success = true, message });
        }

        public static void WriteError(string message, int statusCode = 400)
        {
            Response.StatusCode = statusCode;
            WriteJson(new { success = false, message });
        }
    }
}

EndResponse() is the Pageless equivalent of Response.End() — but without the ThreadAbortException. It flushes buffered content, prevents additional output, and tells ASP.NET to skip to EndRequest cleanup.


PageTemplate — Replaces the Master Page

The PageTemplate class generates the shared HTML shell — <head>, navigation, footer, scripts — that wraps every page. It serves the same role as a .master file, but as a plain C# class.

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;

namespace System.engine
{
    public class PageTemplate
    {
        // --- Page Meta / SEO ---

        string _title = "";

        public string Title
        {
            get
            {
                if (!_title.Contains("My Website"))
                {
                    return _title + " - My Website";
                }
                return _title;
            }
            set
            {
                _title = value;
            }
        }

        public string Description = "Welcome to My Website.";
        public string FaviconIco = "/favicon.ico";
        public string Favicon32 = "/media/favicon-32x32.png";
        public string Favicon16 = "/media/favicon-16x16.png";
        public string AppleTouchIcon = "/media/favicon-180x180.png";
        public string Manifest = "/media/site.webmanifest";
        public string MsAppTileColor = "#E0F3EF";
        public string MsAppTileImage = "/media/favicon-150x150.png";
        public string ThemeColor = "#E0F3EF";
        public string OgType = "website";
        public string OgUrl = "https://mywebsite.com";
        public string OgImage = "https://mywebsite.com/media/og-image.png";
        public int OgImageWidth = 1200;
        public int OgImageHeight = 630;
        public string TwitterCard = "summary_large_image";

        // --- Extra Raw HTML ---

        public string ExtraHeaderText = "";
        public string ExtraFooterText = "";

        // ==============================
        // GenerateHtmlHeader
        // ==============================

        public string GenerateHtmlHeader()
        {
            string encodedTitle = HttpUtility.HtmlEncode(Title);
            string encodedDesc = HttpUtility.HtmlEncode(Description);

            StringBuilder sb = new StringBuilder();

            sb.Append($@"<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8' />
    <meta name='viewport' content='width=device-width, initial-scale=1.0' />
    <title>{encodedTitle}</title>
    <meta name='description' content='{encodedDesc}'>

    <!-- Favicon -->
    <link rel='icon' href='{FaviconIco}' sizes='48x48'>
    <link rel='icon' type='image/png' sizes='32x32' href='{Favicon32}'>
    <link rel='icon' type='image/png' sizes='16x16' href='{Favicon16}'>
    <link rel='apple-touch-icon' sizes='180x180' href='{AppleTouchIcon}'>
    <link rel='manifest' href='{Manifest}'>
    <meta name='msapplication-TileColor' content='{MsAppTileColor}'>
    <meta name='msapplication-TileImage' content='{MsAppTileImage}'>
    <meta name='theme-color' content='{ThemeColor}'>

    <!-- Open Graph / Facebook -->
    <meta property='og:type' content='{OgType}'>
    <meta property='og:url' content='{OgUrl}'>
    <meta property='og:title' content='{encodedTitle}'>
    <meta property='og:description' content='{encodedDesc}'>
    <meta property='og:image' content='{OgImage}'>
    <meta property='og:image:width' content='{OgImageWidth}'>
    <meta property='og:image:height' content='{OgImageHeight}'>

    <!-- Twitter -->
    <meta name='twitter:card' content='{TwitterCard}'>
    <meta name='twitter:url' content='{OgUrl}'>
    <meta name='twitter:title' content='{encodedTitle}'>
    <meta name='twitter:description' content='{encodedDesc}'>
    <meta name='twitter:image' content='{OgImage}'>

    <link rel='stylesheet' href='/css/site.css' />
");

            // Extra header text (raw HTML)
            if (ExtraHeaderText.Length > 0)
            {
                sb.AppendLine(ExtraHeaderText);
            }

            sb.Append(@"</head>
<body>
");

            // --- Navigation Bar ---
            sb.Append(RenderNavbar());

            // --- Open Main Content Container ---
            sb.Append(@"    <main class='site-main'>
");

            return sb.ToString();
        }

        // ==============================
        // GenerateHtmlFooter
        // ==============================

        public string GenerateHtmlFooter()
        {
            StringBuilder sb = new StringBuilder();

            sb.Append($@"
    </main>

    <footer class='site-footer'>
        <div class='footer-inner'>
            <p>&copy; {DateTime.Now.Year} My Website</p>
            <p>
                <a href='/about'>About</a> - 
                <a href='/contact'>Contact</a>
            </p>
        </div>
    </footer>

    <script src='/js/site.js'></script>
");

            // Extra footer text (raw HTML)
            if (ExtraFooterText.Length > 0)
            {
                sb.AppendLine(ExtraFooterText);
            }

            sb.Append(@"
</body>
</html>");

            return sb.ToString();
        }

        // ==============================
        // Navigation Bar
        // ==============================

        string RenderNavbar()
        {
            StringBuilder sb = new StringBuilder();

            sb.Append(@"    <header class='site-header'>
        <div class='header-inner'>
            <a href='/' class='site-logo'>
                <img src='/media/logo-40x40.png' />
            </a>
            <a href='/' class='site-logo'>My Website</a>
            <button class='nav-toggle' onclick='toggleNav()' aria-label='Menu'>
                <span></span><span></span><span></span>
            </button>
            <nav class='site-nav' id='siteNav'>
                <a href='/'>Home</a>
                <a href='/about'>About</a>
                <a href='/contact'>Contact</a>
");

            // Session-aware content — read from custom AppSession (not HttpContext.Session)
            if (AppSession.IsLoggedIn)
            {
                string username = AppSession.LoginUser.Username;
                sb.Append($"                <a href='/u/{HttpUtility.HtmlAttributeEncode(username)}'>Profile</a>\n");
                sb.Append("                <a href='/logout'>Logout</a>\n");
            }
            else
            {
                sb.Append("                <a href='/login'>Login</a>\n");
                sb.Append("                <a href='/register'>Register</a>\n");
            }

            sb.Append(@"            </nav>
        </div>
    </header>
    <div class='nav-overlay' id='navOverlay' onclick='toggleNav()'></div>

");

            return sb.ToString();
        }
    }
}

Using PageTemplate in a Page Handler

Every page handler follows the same pattern — configure metadata, render begin, append page content, render end:

public class HomePage
{
    public static void HandleRequest()
    {
        HttpResponse Response = HttpContext.Current.Response;
        StringBuilder sb = new StringBuilder();

        PageTemplate pt = new PageTemplate()
        {
            Title = "Home",
            Description = "Welcome to our website."
        };

        // Shared header + navbar + container open
        sb.Append(pt.GenerateHtmlHeader());

        // --- Page-specific content ---
        sb.Append("<h1>Welcome</h1>");
        sb.Append("<p>Browse our latest content below.</p>");
        // --- End page-specific content ---

        // Shared footer + scripts + close
        sb.Append(pt.GenerateHtmlFooter());

        Response.ContentType = "text/html; charset=utf-8";
        Response.Write(sb.ToString());
        ApiHelper.EndResponse();
    }
}

Adding Page-Specific CSS and JavaScript

Use lstTopCss, lstTopScript, and lstBottomScript to inject page-specific resources:

PageTemplate pt = new PageTemplate()
{
    Title = "Book Catalog",
    Description = "Browse our collection of books."
};

string extraHeaderText = @"
<link href='/css/books.css' rel='stylesheet'>
<script src='/js/books.js'></script>
";

pt.ExtraHeaderText = extraHeaderText;

sb.Append(pt.GenerateHtmlHeader());

// ... page content ...
sb.Append("....");

sb.Append(pt.GenerateHtmlFooter());

Equivalence to Master Page

Master Page ConceptPageless Equivalent
.master filePageTemplate class
<head> sectionGenerateHtmlHeader()
Navigation barRenderNavbar()
<asp:ContentPlaceHolder>Gap between GenerateHtmlHeader() and GenerateHtmlFooter()
Footer + closing tagsGenerateHtmlFooter()
ContentPlaceHolder for headlstTopCss, lstTopScript, ExtraHeaderText

Frontend Pattern — Fetch API

Data Loading (GET)

const API_URL = '/bookapi';

const response = await fetch(`${API_URL}?action=get_list&id=${id}`);
const data = await response.json();
if (data.success) {
    // Render data.items to DOM
}

Data Saving (POST with FormData)

async function saveBook() {
    var title = document.getElementById('book-title').value.trim();
    var author = document.getElementById('book-author').value.trim();

    var formData = new FormData();
    formData.append('action', 'save-book');
    formData.append('title', title);
    formData.append('author', author);

    try {
        var response = await fetch(API_URL, {
            method: 'POST',
            // Important: Do NOT set Content-Type header when using FormData
            // The browser will automatically set it to multipart/form-data with correct boundary
            body: formData
        });

        if (!response.ok) {
            throw new Error('Server responded with an error status');
        }

        var data = await response.json();

        if (data.success) {
            alert(data.message);
        } else {
            alert(data.message);
        }
    } catch (error) {
        console.error('Fetch error:', error);
    }
}

File Upload (XMLHttpRequest with Progress)

async function uploadFile() {
    var fileInput = document.getElementById('fileUpload');
    if (!fileInput.files.length) return;

    var formData = new FormData();
    formData.append('action', 'upload');
    formData.append('file', fileInput.files[0]);

    // Use XMLHttpRequest for progress tracking
    var xhr = new XMLHttpRequest();

    xhr.upload.onprogress = function (e) {
        if (e.lengthComputable) {
            var pct = Math.round((e.loaded / e.total) * 100);
            document.getElementById('progress').textContent = pct + '%';
        }
    };

    xhr.onload = function () {
        var data = JSON.parse(xhr.responseText);
        if (data.success) alert('Uploaded');
        else alert(data.message);
    };

    xhr.open('POST', API_URL);
    xhr.send(formData);
}

HTML Escaping in JavaScript

function escapeHtml(text) {
    var div = document.createElement('div');
    div.textContent = text;
    return div.innerHTML;
}

Backend API Pattern

API Handler Structure

public class BookPageApi
{
    public static void HandleRequest()
    {
        var Request = HttpContext.Current.Request;
        string action = (Request["action"] + "").ToLower().Trim();

        try
        {
            switch (action)
            {
                case "get-books-html":  GetBooksHtml();  break;
                case "get-books-json":  GetBooksJson();  break;
                case "get-book":        GetBook();       break;
                case "save-book":       SaveBook();      break;
                case "delete-book":     DeleteBook();    break;
                default: ApiHelper.WriteError($"Unknown action: {action}", 400); break;
            }
        }
        catch (Exception ex)
        {
            ApiHelper.WriteError(ex.Message, 500);
        }

        ApiHelper.EndResponse();
    }
}

READ — Return HTML Fragment

The server pre-renders HTML. The frontend dumps it into a container with innerHTML:

Backend:

static void GetBooksHtml()
{
    HttpResponse Response = HttpContext.Current.Response;
    StringBuilder sb = new StringBuilder();

    List<obBook> lstBook = GetBooksFromDatabase();

    foreach (var b in lstBook)
    {
        sb.Append($@"
<div class='card-book'>
    <strong>{HttpUtility.HtmlEncode(b.Title)}</strong><br>
    Author: {HttpUtility.HtmlEncode(b.Author)}<br>
    Year: {b.Year}<br>
    <button type='button' onclick='editBook({b.Id})'>Edit</button>
    <button type='button' onclick='deleteBook({b.Id})'>Delete</button>
</div>");
    }

    Response.ContentType = "text/html; charset=utf-8";
    Response.Write(sb.ToString());
}

Frontend:

async function getAllBooksHtml() {
    var formData = new FormData();
    formData.append('action', 'get-books-html');

    try {
        var response = await fetch(API_URL, {
            method: 'POST',
            body: formData
        });

        var html = await response.text();
        document.getElementById('div-my-books').innerHTML = html;
    } catch (e) {
        alert('Failed to load books');
    }
}

READ — Return JSON

The server returns data. The frontend renders it in JavaScript:

Backend:

static void GetBooksJson()
{
    List<obBook> lstBook = GetBooksFromDatabase();

    ApiHelper.WriteJson(new
    {
        success = true,
        message = "Success",
        books = lstBook
    });
}

Frontend:

async function getAllBooksJson() {
    var formData = new FormData();
    formData.append('action', 'get-books-json');

    try {
        var response = await fetch(API_URL, {
            method: 'POST',
            body: formData
        });

        var data = await response.json();

        if (!data.success) {
            alert(data.message);
            return;
        }

        var blocks = [];

        for (var i = 0; i < data.books.length; i++) {
            var b = data.books[i];
            blocks.push(
                "<div class='card-book'>" +
                "<strong>" + escapeHtml(b.Title) + "</strong><br>" +
                "Author: " + escapeHtml(b.Author) + "<br>" +
                "Year: " + b.Year + "<br>" +
                "<button type='button' onclick='editBook(" + b.Id + ")'>Edit</button> " +
                "<button type='button' onclick='deleteBook(" + b.Id + ")'>Delete</button>" +
                "</div>"
            );
        }

        document.getElementById('div-my-books').innerHTML = blocks.join('');
    } catch (e) {
        alert('Failed to load books');
    }
}

CREATE + UPDATE (2-in-1): SaveBook

If id is empty or zero → INSERT (Create). If id has a value → UPDATE.

Backend:

static void SaveBook()
{
    HttpRequest Request = HttpContext.Current.Request;

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

    string title = (Request.Form["title"] + "").Trim();
    string author = (Request.Form["author"] + "").Trim();
    int year = 0;
    int.TryParse(Request.Form["year"] + "", out year);

    // Validation
    if (string.IsNullOrEmpty(title))
    {
        ApiHelper.WriteError("Title is required");
        return;
    }

    if (string.IsNullOrEmpty(author))
    {
        ApiHelper.WriteError("Author is required");
        return;
    }

    if (year < 1 || year > DateTime.Now.Year)
    {
        ApiHelper.WriteError("Please enter a valid year");
        return;
    }

    using (MySqlConnection conn = new MySqlConnection(config.ConnString))
    {
        conn.Open();
        using (MySqlCommand cmd = new MySqlCommand())
        {
            cmd.Connection = conn;
            MySqlExpress m = new MySqlExpress(cmd);

            Dictionary<string, object> dic = new Dictionary<string, object>();
            dic["title"] = title;
            dic["author"] = author;
            dic["year"] = year;

            if (id <= 0)
            {
                // CREATE — no id, insert new row
                m.Insert("books", dic);
                ApiHelper.WriteSuccess("Book added");
            }
            else
            {
                // UPDATE — has id, update existing row
                m.Update("books", dic, "id", id);
                ApiHelper.WriteSuccess("Book updated");
            }
        }
    }
}

Frontend:

async function saveBook() {
    var id = document.getElementById('book-id').value.trim();
    var title = document.getElementById('book-title').value.trim();
    var author = document.getElementById('book-author').value.trim();
    var year = document.getElementById('book-year').value.trim();

    var formData = new FormData();
    formData.append('action', 'save-book');
    formData.append('id', id);       // empty = create, has value = update
    formData.append('title', title);
    formData.append('author', author);
    formData.append('year', year);

    try {
        var response = await fetch(API_URL, {
            method: 'POST',
            body: formData
        });

        var data = await response.json();

        if (data.success) {
            alert(data.message);
            clearForm();
            getAllBooksHtml(); // Refresh the list
        } else {
            alert(data.message);
        }
    } catch (e) {
        alert('Something went wrong. Please try again.');
    }
}

DELETE

Backend:

static void DeleteBook()
{
    HttpRequest Request = HttpContext.Current.Request;

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

    if (id <= 0)
    {
        ApiHelper.WriteError("Invalid book ID");
        return;
    }

    using (MySqlConnection conn = new MySqlConnection(config.ConnString))
    {
        conn.Open();
        using (MySqlCommand cmd = new MySqlCommand())
        {
            cmd.Connection = conn;
            MySqlExpress m = new MySqlExpress(cmd);

            var p = new Dictionary<string, object>();
            p["@id"] = id;

            m.Execute("DELETE FROM books WHERE id = @id;", p);
        }
    }

    ApiHelper.WriteSuccess("Book deleted");
}

Frontend:

async function deleteBook(id) {
    if (!id) {
        id = document.getElementById('book-id').value.trim();
    }

    if (!id) {
        alert('No book selected');
        return;
    }

    if (!confirm('Delete this book?')) return;

    var formData = new FormData();
    formData.append('action', 'delete-book');
    formData.append('id', id);

    try {
        var response = await fetch(API_URL, {
            method: 'POST',
            body: formData
        });

        var data = await response.json();

        if (data.success) {
            alert(data.message);
            clearForm();
            getAllBooksHtml();
        } else {
            alert(data.message);
        }
    } catch (e) {
        alert('Something went wrong. Please try again.');
    }
}

API Response Format

All API responses follow this JSON structure:

// Success
{ "success": true, "message": "..." }

// Success with data
{ "success": true, "message": "...", "data": {...} }

// Success with list
{ "success": true, "items": [...] }

// Error
{ "success": false, "message": "Error description" }

File Upload Pattern

Frontend:

async function uploadFile() {
    var fileInput = document.getElementById('fileUpload');
    if (!fileInput.files.length) return;

    var formData = new FormData();
    formData.append('action', 'upload');
    formData.append('parent_id', parentId);
    formData.append('file', fileInput.files[0]);

    var xhr = new XMLHttpRequest();

    xhr.upload.onprogress = function (e) {
        if (e.lengthComputable) {
            var pct = Math.round((e.loaded / e.total) * 100);
            document.getElementById('progress').textContent = pct + '%';
        }
    };

    xhr.onload = function () {
        var data = JSON.parse(xhr.responseText);
        if (data.success) alert('Uploaded');
        else alert(data.message);
    };

    xhr.open('POST', API_URL);
    xhr.send(formData);
}

Backend:

static void UploadFile()
{
    HttpRequest Request = HttpContext.Current.Request;

    if (Request.Files.Count == 0)
    {
        ApiHelper.WriteError("No file uploaded");
        return;
    }

    var uploadedFiles = new List<object>();

    for (int i = 0; i < Request.Files.Count; i++)
    {
        HttpPostedFile file = Request.Files[i];

        if (file.ContentLength == 0)
            continue;

        string fileName = Path.GetFileName(file.FileName);
        string savePath = HttpContext.Current.Server.MapPath("~/uploads/" + fileName);

        try
        {
            file.SaveAs(savePath);

            uploadedFiles.Add(new
            {
                success = true,
                fileName = fileName,
                filePath = "/uploads/" + fileName
            });
        }
        catch (Exception ex)
        {
            uploadedFiles.Add(new
            {
                success = false,
                fileName = fileName,
                message = ex.Message
            });
        }
    }

    ApiHelper.WriteJson(uploadedFiles);
}

Photo by [zhang kaiyv](https://unsplash.com/@zhangkaiyv?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText) on [Unsplash](https://unsplash.com/photos/time-lapse-photography-of-cars-passing-through-the-road-between-buildings-during-night-time-o6U9HanLq6U?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText)