CRUD in Pageless ASP.NET Web Forms Architecture

Create, Read, Update, Delete — with C# string-based rendering, plain HTML forms, and the Fetch API. No GridView, no ViewState, no page lifecycle, no frontend framework.


Introduction

CRUD — Create, Read, Update, Delete — is the foundation of almost every web application. User accounts, blog posts, product catalogs, forum threads, invoices, inventory — all of it is CRUD.

At the HTTP level, CRUD is simple:

  • Create — The browser sends a POST request with form data. The server inserts a row into the database.
  • Read — The browser sends a GET request. The server queries the database and returns HTML.
  • Update — The browser sends a POST request with an ID and new values. The server updates the row.
  • Delete — The browser sends a POST request with an ID. The server deletes the row.

That’s it. Key-value pairs go up. HTML or JSON comes back. Every web framework in existence wraps this in abstractions — model binding, data annotations, form tag helpers, component state, two-way binding, reactive stores. Pageless Web Forms exposes it directly.

In Pageless Architecture, every feature follows the Two-Handler Pattern:

  1. A Page Handler — renders the HTML page. This is a static C# method that builds HTML with StringBuilder and writes it to Response. It handles the display side of CRUD — showing forms, rendering lists, displaying detail views.
  2. An API Handler — processes form submissions. This is a static C# method that reads Request.Form["action"], switches on it, performs database operations, and returns a response (HTML fragment or JSON). It handles the operation side of CRUD — inserting, updating, deleting records.

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

This article walks through a complete CRUD implementation for a book catalog — a list of books with title, author, and year. Every operation is shown as a pair: the C# backend code and the JavaScript frontend code that calls it.

Companion article: C# String-Based HTML Template Rendering in ASP.NET Web Forms covers the rendering technique in detail — PageHeader, PageTemplate, Response.Write, and pipeline interception. This article focuses on the data operations.


Routing

Assume that the website has a URL path like this:

https://www.mybooks.com/books
https://www.mybooks.com/bookapi

or

https://localhost:8080/books
https://localhost:8080/bookapi

Which from the web application perspective, it sees this path:

/books
/bookapi

Different paths will lead to different handling. In web development terminology, the handling of the paths request task delegation is called “routes” or “routing”.

All routes are defined in Global.asax.cs. The switch statement is the routing table:

protected void Application_PostAcquireRequestState(object sender, EventArgs e)
{
    string path = Request.Path.ToLower().Trim().TrimEnd('/');

    switch (path)
    {
        case "/books":
            BookPage.HandleRequest();
            return;

        case "/bookapi":
            BookApi.HandleRequest();
            return;
    }
}

Two routes. One page, one API. That pattern scales to the entire application — add more case entries as you add features. No route configuration files, no attribute routing, no convention-based discovery. You look at the switch statement and you see every URL the application responds to.

In above examples:

The path of /books is for a full HTML page request.

And the path of /bookapi is for POST API request.


READ — Full Page Rendering

Let’s first dive into the first operation “READ”. There are 2 commonly seen ways of handling the “READ” operation, the traditional sense of “R” (Read). Namely:

  • First, “READ” as part of a full HTML page request, /books.
  • Second, “READ” as a partial update, like SPA, execute through JavaScript Fetch API or AJAX XMLHttpRequest.

Let’s first go through the “READ” as part of a full HTML page request. This concept has already been fully covered in detail in the companion article C# String-Based HTML Template Rendering in ASP.NET Web Forms. Here, we’ll do a quick recap for the basics:

The server builds the entire HTML document — from <!DOCTYPE html> to </html> — in a single pass. The browser receives a complete page. This is the fastest approach for the user because there is only one HTTP request and the content is immediately visible.

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

        // Render the HTML document header
        sb.Append(@"
<!DOCTYPE html>
<html>
<head>
    <meta charset='utf-8'>
    <title>Book Catalog</title>
    <meta name='description' content='Browse our collection of books'>
    <link rel='stylesheet' href='/css/site.css'>
</head>
<body>
");

        // Page content
        sb.Append("<h1>Book Catalog</h1>");

        // READ from database and render
        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>");
        }

        // Close the HTML document
        sb.Append(@"
    <script src='/js/books.js'></script>
</body>
</html>");

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

    static List<obBook> GetBooksFromDatabase()
    {
        List<obBook> lst = new List<obBook>();

        using (MySqlConnection conn = new MySqlConnection(config.ConnString))
        {
            conn.Open();
            using (MySqlCommand cmd = new MySqlCommand())
            {
                cmd.Connection = conn;
                MySqlExpress m = new MySqlExpress(cmd);
                lst = m.GetObjectList<obBook>(
                    "SELECT * FROM books ORDER BY title;");
            }
        }

        return lst;
    }
}

You’ll notice there is a special method shown in above code:

ApiHelper.EndResponse();

The EndResponse helper terminates the ASP.NET pipeline cleanly, required in Pageless ASP.NET Web Forms Architecture. This will be discussed at the end of the article alongside with other helper methods.

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();
}

One request. One response. The browser renders the page immediately. No JavaScript required for the initial display.


Okay, that was a long intro. Nonetheless, it serves as a stepping stone for what we’ll be discussing next.

Now, we’ll begin to dive into the topics that we came here to discuss. The CRUD actions calling through what the web development universe calls an “API”.


The Frontend — HTML Form and JavaScript

Let’s begin from the frontend perspective.

Let’s design the JavaScript functions that cover all common CRUD operations seen in web development.

First, the HTML form:

<button type='button' onclick='saveBook();'>Save</button>
<button type='button' onclick='deleteBook();'>Delete</button>
<button type='button' onclick='clearForm();'>Clear</button>

<div class="edit-form">
    <input type='hidden' id='book-id' />

    ID: <span id='book-id-display'></span><br>
    Title: <input id='book-title' type='text'><br>
    Author: <input id='book-author' type='text'><br>
    Year: <input id='book-year' type='number'>
</div>

<button type='button' onclick='getAllBooksHtml();'>Get All Books (HTML)</button>
<button type='button' onclick='getAllBooksJson();'>Get All Books (JSON)</button>

<div id='div-my-books'></div>

<script>
    const API_URL = '/bookapi';

    async function getAllBooksHtml() {

        // api action: get-books-html
    }

    async function getAllBooksJson() {

        // api action: get-books-json
    }

    async function editBook(id) {

        // api action: get-book
    }

    async function saveBook() {

        // api action: save-book
        // CREATE and UPDATE (2-in-1)
    }

    async function deleteBook(id) {

        // api action: delete-book
    }

    // UI helper

    function clearForm() {
        document.getElementById('book-id').value = '';
        document.getElementById('book-id-display').textContent = '';
        document.getElementById('book-title').value = '';
        document.getElementById('book-author').value = '';
        document.getElementById('book-year').value = '';
    }

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

Let’s look at the implementation of the first two function: getAllBooksHtml() and getAllBooksJson():

const API_URL = '/bookapi';

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

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

        // Backend server pre-rendered the HTML
        // Frontend just dumps it into the container
        var html = await response.text();
        document.getElementById('div-my-books').innerHTML = html;

    } catch (e) {
        alert('Failed to load books');
    }
}

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 = [];

        // JavaScript rendering
        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');
    }
}

Here’s the C# code at the server. It all begins at Global.asax:

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

        switch (path)
        {
            case "/books":
                BookPage.HandleRequest();
                return;

            case "/bookapi":
                BookApi.HandleRequest();
                return;
        }
    }
}

The Class of BookApi:

public class BookApi
{
    public static void HandleRequest()
    {
        var Request = HttpContext.Current.Request;

        // Obtain the action
        string action = (Request.Form["action"] + "").ToLower().Trim();

        try
        {
            // Define all the action handling
            switch (action)
            {
                // READ
                case "get-books-html":
                    GetBooksHtml();
                    break;
                case "get-books-json":
                    GetBooksJson();
                    break;
                case "get-book":
                    GetBook();
                    break;

                // CREATE + UPDATE (2 in 1)
                case "save-book":
                    SaveBook();
                    break;

                // DELETE
                case "delete-book":
                    DeleteBook();
                    break;

                // Unknown action
                default:
                    ApiHelper.WriteError("Unknown action", 400);
                    break;
            }
        }
        catch (Exception ex)
        {
            ApiHelper.WriteError(ex.Message, 500);
        }

        ApiHelper.EndResponse();
    }

    static void GetBooksHtml()
    {

    }

    static void GetBooksJson()
    {

    }

    static void GetBook()
    {

    }

    static void SaveBook()
    {

    }

    static void DeleteBook()
    {

    }
}

Now, let’s look at the first two methods that answering the JavaScript call:

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());
}

static void GetBooksJson()
{
    HttpResponse Response = HttpContext.Current.Response;

    List<obBook> lstBook = GetBooksFromDatabase();

    var result = new
    {
        success = true,
        message = "Success",
        books = lstBook
    };

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

Now, let’s continue for the rest of the JavaScript api call:

const API_URL = '/bookapi';

// READ — Get single book (for editing)
async function editBook(id) {
    var formData = new FormData();
    formData.append('action', 'get-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);
            return;
        }

        // Fill the form with the book's current values
        var b = data.book;
        document.getElementById('book-id').value = b.Id;
        document.getElementById('book-id-display').textContent = b.Id;
        document.getElementById('book-title').value = b.Title;
        document.getElementById('book-author').value = b.Author;
        document.getElementById('book-year').value = b.Year;

    } catch (e) {
        alert('Failed to load book');
    }
}

// CREATE + UPDATE (2 in 1)
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
async function deleteBook(id) {
    // If called from the book list (with id parameter)
    // or from the form (read id from hidden field)
    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(); // Refresh the list
        } else {
            alert(data.message);
        }

    } catch (e) {
        alert('Something went wrong. Please try again.');
    }
}

That’s the entire frontend. Six functions.

Notice the pattern — every function does the same thing:

  1. Create a FormData object
  2. Append action (tells the server what to do)
  3. Append any data fields
  4. fetch POST to /bookapi
  5. Read the JSON response
  6. Check data.success — show message or handle error

The saveBook function handles both Create and Update. If book-id is empty, the server creates a new record. If book-id has a value, the server updates the existing record. One function, one API action, two operations.

The editBook function fetches a single book’s data from the server and fills the form fields. The user edits the values and clicks “Save” — which calls saveBook with the book-id populated, so the server knows it’s an update.


Now the remaining backend C# api handling:

READ: GetBook

Returns a single book for the edit form.

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

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

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

    obBook book = null;

    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;

            book = m.GetObject<obBook>(
                "SELECT * FROM books WHERE id = @id LIMIT 1;", p);
        }
    }

    if (book == null)
    {
        ApiHelper.WriteError("Book not found");
        return;
    }

    ApiHelper.WriteJson(new { success = true, book });
}

CREATE + UPDATE: SaveBook

One method handles both. If the id is empty or zero, it’s a Create (INSERT). If the id has a value, it’s an Update (UPDATE).

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");
            }
        }
    }
}

The id field does all the work. The frontend sends id = "" for new books and id = 42 for existing ones. The backend checks: no id → INSERT, has id → UPDATE. One function. One action name. Two operations.

DELETE: DeleteBook

The simplest operation. Receive the ID, delete the row.

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");
}

Helper — ApiHelper

The response helper methods used throughout:

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

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

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

    public static void EndResponse()
    {
        try { HttpContext.Current.Response.Flush(); } catch { }
        HttpContext.Current.Response.SuppressContent = true;
        HttpContext.Current.ApplicationInstance.CompleteRequest();
    }
}

Happy CRUD.