A technique for rendering full HTML pages entirely in C# — from <!DOCTYPE html> to </html> — using string interpolation, StringBuilder, and Response.Write, without any .aspx markup files, master pages, or the Web Forms page lifecycle.
Introduction
Every web application, regardless of framework, does the same thing at its core: receive an HTTP request, process it, and return an HTTP response. For HTML pages, the response is a string of text — an HTML document — sent back to the browser with a Content-Type: text/html header.
Frameworks differ in how they construct that HTML string. Some use template files with embedded code (.aspx, .cshtml, .php, .jsp). Some use a component tree that renders itself into HTML. Some use a virtual DOM that diffs and patches.
But underneath all of those approaches, the final output is always the same: a string of HTML written to the response stream.
C# String-Based HTML Template Rendering takes the most direct path. Instead of loading a template file and injecting values into it, you build the HTML string directly in C# code — using string interpolation, StringBuilder, and Response.Write. The HTML lives in your C# code. The rendering is the code.
Where Is This Technique Used?
This technique appears at two levels in ASP.NET Web Forms development:
Vanilla Web Forms — The application uses a master page (.master) for the shared HTML skeleton and .aspx pages for specific content. Within those pages, portions of the output are built using C# string-based rendering — typically dynamic content like product lists, user profiles, or forum threads — rendered into Literal controls via StringBuilder. The page structure comes from template files; the dynamic content comes from C# strings.
Pageless Web Forms — The entire HTML page is rendered in C# from start to end. No .aspx files. No master page. No template files of any kind. The full document — <!DOCTYPE html>, <head>, <body>, navigation, content, footer, closing tags — is constructed in C# and written directly to Response. This is full C# String-Based HTML Template Rendering.
This article covers the full-page approach used in Pageless Web Forms.
Entry Points: Where Pageless Rendering Begins
In Pageless Web Forms, the application does not route requests to .aspx files. Instead, requests are intercepted at Global.asax.cs pipeline events before any page handler is instantiated.
There are two typical interception points:
Application_BeginRequest — The earliest event in the ASP.NET HTTP pipeline. The request has been received and parsed by IIS. HttpContext.Current.Request is fully available — URL, headers, query string, cookies, form data, request body. However, HttpContext.Current.Session is null at this point because the SessionStateModule has not yet executed. Use this for stateless operations: API endpoints, webhook handlers, static content generation, system administration endpoints.
Application_PostAcquireRequestState — Fires after the SessionStateModule has loaded session data. HttpContext.Current.Session is now fully available. The page lifecycle has still not started — no .aspx file has been loaded, no control tree has been instantiated, no ViewState has been processed. Use this for any request that needs session state: pages that check login status, display user-specific content, or process authenticated form submissions.
Both entry points use the same routing and rendering pattern:
// Intercept at BeginRequest — no session state available
protected void Application_BeginRequest(object sender, EventArgs e)
{
string path = Request.Path.ToLower().Trim().TrimEnd('/');
switch (path)
{
case "/":
case "/home":
HandleHomeRequest();
break;
case "/about":
HandleAboutRequest();
break;
case "/contact":
HandleContactRequest();
break;
}
}
// Intercept at PostAcquireRequestState — session state available
protected void Application_PostAcquireRequestState(object sender, EventArgs e)
{
if (HttpContext.Current?.Session != null)
AppSession.TryRestoreFromCookie();
string path = Request.Path.ToLower().Trim().TrimEnd('/');
switch (path)
{
case "/":
case "/home":
HandleHomeRequest();
break;
case "/about":
HandleAboutRequest();
break;
case "/contact":
HandleContactRequest();
break;
}
}
Yes, both are identical in structure. The only difference is when they fire in the pipeline and what is available to the handler methods. The rendering technique itself is the same regardless of which entry point you use.
Part 1: Basic Walkthrough
The Simplest Possible Page
Let’s start with the most basic case — a single method that renders a complete HTML page:
public static void HandleHomeRequest()
{
HttpResponse Response = HttpContext.Current.Response;
string html = @"
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Hello World</title>
</head>
<body>
<h1>Hello World</h1>
<p>This is a simple HTML page rendered entirely in C#.</p>
</body>
</html>";
Response.ContentType = "text/html";
Response.Write(html);
// Terminate the response — prevent ASP.NET from continuing the pipeline
try { Response.Flush(); } catch { }
Response.SuppressContent = true;
HttpContext.Current.ApplicationInstance.CompleteRequest();
}
That’s it. The entire HTML document is a C# string. Response.Write sends it to the browser. The three lines at the end — Flush, SuppressContent, CompleteRequest — terminate the response and tell ASP.NET to skip the rest of the pipeline. No .aspx file was loaded. No page lifecycle executed. No master page was consulted.
The browser receives a complete HTML document and renders it normally. It has no idea how the HTML was generated — it could have come from a template file, a framework, or a string literal in C#. The browser doesn’t care. It just sees HTML.
Adding Dynamic Content
A static HTML string is not very useful. The power of string-based rendering comes from inserting dynamic data. Here’s a page that renders a list of products from a database:
public static void HandleProductsRequest()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
// HTML head
sb.Append(@"
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>Products - My Website</title>
</head>
<body>
<h1>Our Products</h1>");
// Dynamic content from database
List<obProduct> lstProduct = GetProductsFromDatabase();
foreach (var product in lstProduct)
{
sb.Append($@"
<div class='product-card'>
<h2>{HttpUtility.HtmlEncode(product.Name)}</h2>
<p>{HttpUtility.HtmlEncode(product.Description)}</p>
<span>Category: {HttpUtility.HtmlEncode(product.Category)}</span><br>
<a href='/product/{product.Id}'>Read More</a>
</div>");
}
// Close the HTML document
sb.Append(@"
</body>
</html>");
Response.ContentType = "text/html";
Response.Write(sb.ToString());
try { Response.Flush(); } catch { }
Response.SuppressContent = true;
HttpContext.Current.ApplicationInstance.CompleteRequest();
}
The pattern is straightforward: open the HTML document as a string, loop through data and append dynamic content using string interpolation, close the document, write the whole thing to the response. The StringBuilder is used instead of string concatenation for performance — each Append call adds to an internal buffer without creating intermediate string objects.
Note the use of HttpUtility.HtmlEncode on every piece of user-supplied data. This is critical for security — it prevents cross-site scripting (XSS) by escaping characters like <, >, &, and " in the output. Any data that originated from a database, a form submission, or a URL parameter must be HTML-encoded before being inserted into the HTML string.
The Problem: Repeated Boilerplate
Looking at the two examples above, a problem is already visible. Every page handler repeats the same boilerplate: the <!DOCTYPE html> declaration, the <meta charset> tag, the viewport meta tag, the <head> section structure, the response termination logic. If you have 20 pages, you have 20 copies of this boilerplate.
In conventional Web Forms, the master page solves this — shared layout lives in one .master file. In Pageless Web Forms, we solve it with reusable C# classes.
Part 2: Reusable Page Components
The Page Header Class
The <head> section of an HTML page contains metadata that varies per page — title, description, Open Graph tags for social sharing, favicon links, theme colors. We encapsulate all of this in a PageHeader class with sensible defaults that each page can override:
public class PageHeader
{
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 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";
public string ExtraHeader = "";
public string GenerateHeaderText()
{
StringBuilder sb = new StringBuilder();
sb.Append($@"
<!DOCTYPE html>
<html lang='en'>
<head>
<meta charset='utf-8'>
<meta name='viewport' content='width=device-width, initial-scale=1.0'>
<title>{HttpUtility.HtmlEncode(Title)}</title>
<meta name='description' content='{HttpUtility.HtmlEncode(Description)}'>
<!-- 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='theme-color' content='{ThemeColor}'>
<!-- Open Graph / Facebook -->
<meta property='og:type' content='{OgType}'>
<meta property='og:url' content='{OgUrl}'>
<meta property='og:title' content='{HttpUtility.HtmlEncode(Title)}'>
<meta property='og:description' content='{HttpUtility.HtmlEncode(Description)}'>
<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='{HttpUtility.HtmlEncode(Title)}'>
<meta name='twitter:description' content='{HttpUtility.HtmlEncode(Description)}'>
<meta name='twitter:image' content='{OgImage}'>
<!-- CSS -->
<link rel='stylesheet' href='/css/site.css'>
{ExtraHeader}
</head>
<body>");
return sb.ToString();
}
}
The PageHeader class serves the same purpose as the <head> section of a master page — but it’s a plain C# class, not a template file. Every field has a default value. Pages only override what they need.
The Title property has a getter that automatically appends the site name if it’s not already present — so setting Title = "Products" produces "Products - My Website" in the rendered output, while setting Title = "My Website" leaves it unchanged. This is a small convenience that eliminates a common source of inconsistency across pages.
The ExtraHeader field allows individual pages to inject page-specific CSS or JavaScript into the <head> section — the equivalent of a ContentPlaceHolder in a master page, but as a simple string.
Using the Page Header
With PageHeader encapsulated, each page handler becomes cleaner:
public static void HandleHomeRequest()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
// Configure per-page metadata
PageHeader ph = new PageHeader()
{
Title = "Home",
Description = "Welcome to our website. Browse our products and services."
};
// Render the header (everything from <!DOCTYPE html> through <body>)
sb.Append(ph.GenerateHeaderText());
// Page-specific body content
sb.Append("<h1>Welcome to My Website</h1>");
sb.Append("<p>Browse our latest products below.</p>");
// Dynamic content from database
List<obProduct> lstProduct = GetProductsFromDatabase();
foreach (var product in lstProduct)
{
sb.Append($@"
<div class='product-card'>
<h2>{HttpUtility.HtmlEncode(product.Name)}</h2>
<p>{HttpUtility.HtmlEncode(product.Description)}</p>
<a href='/product/{product.Id}'>Read More</a>
</div>");
}
// Close the document
sb.Append("</body>");
sb.Append("</html>");
// Write and terminate
Response.ContentType = "text/html";
Response.Write(sb.ToString());
try { Response.Flush(); } catch { }
Response.SuppressContent = true;
HttpContext.Current.ApplicationInstance.CompleteRequest();
}
And a completely different page reuses the same PageHeader with different values:
public static void HandleAboutRequest()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
PageHeader ph = new PageHeader()
{
Title = "About Us",
Description = "Learn about our company, mission, and team."
};
sb.Append(ph.GenerateHeaderText());
sb.Append("<h1>About Us</h1>");
sb.Append("<p>We are a small team building great software.</p>");
sb.Append("</body>");
sb.Append("</html>");
Response.ContentType = "text/html";
Response.Write(sb.ToString());
try { Response.Flush(); } catch { }
Response.SuppressContent = true;
HttpContext.Current.ApplicationInstance.CompleteRequest();
}
Both pages share the same HTML skeleton, the same favicon configuration, the same Open Graph tags structure, the same CSS link — but each has its own title, description, and body content. The PageHeader class is doing the same job as a master page, but with zero file I/O, zero XML parsing, and zero control tree instantiation.
Extracting the Response Termination
The three-line response termination pattern appears in every handler. Let’s extract it into a helper method:
public static class ApiHelper
{
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();
}
}
Now every handler ends with a single call:
Response.ContentType = "text/html";
Response.Write(sb.ToString());
ApiHelper.EndResponse();This is a small refactoring, but it matters. EndResponse() is the Pageless equivalent of Response.End() — but without the ThreadAbortException that Response.End() throws. It flushes any buffered content, prevents additional content from being written, and tells ASP.NET to skip to the EndRequest cleanup event. The pipeline terminates cleanly.
Part 3: Advanced — Full Page Template Rendering
The basic walkthrough above covers the header and body content, but a real web application has more shared structure: a navigation bar, a sidebar, a footer, JavaScript includes. Every page needs these, and duplicating them across handlers is the same problem we solved for the <head> section.
In conventional Web Forms, this is exactly what the master page does — it wraps every page in a consistent shell of navigation, layout, and footer, with a ContentPlaceHolder where page-specific content goes.
In Pageless Web Forms, a PageTemplate class replaces the master page entirely.
The Page Template Class
public static class PageTemplate
{
public static string RenderBegin(PageHeader ph)
{
StringBuilder sb = new StringBuilder();
// The PageHeader generates everything from <!DOCTYPE html> through <body>
sb.Append(ph.GenerateHeaderText());
// Shared navigation
sb.Append(RenderNavbar());
// Open the main content container
sb.Append("<main class='container'>");
return sb.ToString();
}
public static string RenderEnd()
{
StringBuilder sb = new StringBuilder();
// Close the main content container
sb.Append("</main>");
// Shared footer
sb.Append(RenderFooter());
// JavaScript includes
sb.Append("<script src='/js/site.js'></script>");
// Close the document
sb.Append("</body>");
sb.Append("</html>");
return sb.ToString();
}
static string RenderNavbar()
{
StringBuilder sb = new StringBuilder();
sb.Append("<nav class='navbar'>");
sb.Append("<a href='/' class='nav-brand'>My Website</a>");
sb.Append("<div class='nav-links'>");
sb.Append("<a href='/'>Home</a>");
sb.Append("<a href='/about'>About</a>");
sb.Append("<a href='/contact'>Contact</a>");
// Session-aware content — only works at PostAcquireRequestState
if (HttpContext.Current.Session != null)
{
bool loggedIn = HttpContext.Current.Session["UserId"] != null;
if (loggedIn)
{
string username = HttpContext.Current.Session["Username"] + "";
sb.Append($"<a href='/u/{HttpUtility.HtmlAttributeEncode(username)}'>Profile</a>");
sb.Append("<a href='/logout'>Logout</a>");
}
else
{
sb.Append("<a href='/login'>Login</a>");
sb.Append("<a href='/register'>Register</a>");
}
}
sb.Append("</div>");
sb.Append("</nav>");
return sb.ToString();
}
static string RenderFooter()
{
int year = DateTime.Now.Year;
return $@"
<footer class='site-footer'>
<p>© {year} My Website. All rights reserved.</p>
<p><a href='/privacy'>Privacy Policy</a> | <a href='/terms'>Terms of Service</a></p>
</footer>";
}
}
The PageTemplate class has two public methods: RenderBegin and RenderEnd. Everything before the page-specific content goes in RenderBegin — the HTML head (via PageHeader), the navigation bar, the opening content container. Everything after goes in RenderEnd — the closing container tag, the footer, JavaScript includes, closing </body> and </html> tags.
The navigation bar demonstrates session-aware rendering. It checks HttpContext.Current.Session to determine whether a user is logged in and renders different links accordingly. This is why the PostAcquireRequestState interception point matters — without session state, the navbar cannot know whether to show “Login” or “Profile.”
Using the Full Template
With PageTemplate in place, every page handler follows the same clean pattern:
public static void HandleHomeRequest()
{
HttpResponse Response = HttpContext.Current.Response;
StringBuilder sb = new StringBuilder();
PageHeader ph = new PageHeader()
{
Title = "Home",
Description = "Welcome to our website."
};
// Shared header + navbar + container open
sb.Append(PageTemplate.RenderBegin(ph));
// --- Page-specific content only ---
sb.Append("<h1>Welcome</h1>");
sb.Append("<p>Browse our latest products below.</p>");
List<obProduct> lstProduct = GetProductsFromDatabase();
foreach (var product in lstProduct)
{
sb.Append($@"
<div class='product-card'>
<h2>{HttpUtility.HtmlEncode(product.Name)}</h2>
<p>{HttpUtility.HtmlEncode(product.Description)}</p>
<a href='/product/{product.Id}'>Read More</a>
</div>");
}
// --- End page-specific content ---
// Shared footer + scripts + close
sb.Append(PageTemplate.RenderEnd());
Response.ContentType = "text/html";
Response.Write(sb.ToString());
ApiHelper.EndResponse();
}
Compare this with the equivalent in conventional Web Forms:
- Master page →
PageTemplate.RenderBegin()+PageTemplate.RenderEnd() ContentPlaceHolder→ the gap betweenRenderBeginandRenderEnd.aspxpage content → the page-specificStringBuilderappends- Code-behind
Page_Load→ the handler method itself
The structure is the same. The mechanism is different. Instead of the framework loading a .master file, parsing its XML, instantiating a control tree, and merging it with the .aspx content at render time — you call two methods that return strings. The result in the browser is identical.
Profile Page — Session-Aware Rendering with Database Access
Here is a more complete example showing session state, database queries, conditional rendering, and proper HTML encoding — a user profile page:
public static void HandleProfileRequest()
{
HttpRequest Request = HttpContext.Current.Request;
HttpResponse Response = HttpContext.Current.Response;
// Read username from URL path or query string
string username = (Request["u"] ?? "").Trim();
// No username specified — redirect logged-in users to their own profile
if (string.IsNullOrEmpty(username))
{
if (HttpContext.Current.Session["UserId"] == null)
{
Response.StatusCode = 404;
ApiHelper.EndResponse();
return;
}
string ownUsername = HttpContext.Current.Session["Username"] + "";
Response.Redirect($"/u/{ownUsername}", true);
return;
}
// Load user from database
obUser user = 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["@username"] = username;
user = m.GetObject<obUser>(
"SELECT * FROM users WHERE username = @username LIMIT 1;", p);
}
}
if (user == null || user.Id == 0)
{
Response.StatusCode = 404;
Response.ContentType = "text/html";
PageHeader ph404 = new PageHeader() { Title = "User Not Found" };
Response.Write(PageTemplate.RenderBegin(ph404));
Response.Write("<p>User not found.</p>");
Response.Write(PageTemplate.RenderEnd());
ApiHelper.EndResponse();
return;
}
// Build the page
StringBuilder sb = new StringBuilder();
PageHeader ph = new PageHeader()
{
Title = $"{user.DisplayName} ({user.Username})",
Description = $"{user.DisplayName} — Personal profile"
};
sb.Append(PageTemplate.RenderBegin(ph));
// Profile header
string displayName = HttpUtility.HtmlEncode(
string.IsNullOrEmpty(user.DisplayName) ? user.Username : user.DisplayName);
sb.Append("<div class='profile-header'>");
sb.Append($"<h1>{displayName}</h1>");
sb.Append($"<div class='profile-username'>@{HttpUtility.HtmlEncode(user.Username)}</div>");
sb.Append("</div>");
// Profile metadata
sb.Append("<div class='profile-meta'>");
if (!string.IsNullOrEmpty(user.Location))
{
sb.Append($"<span>{HttpUtility.HtmlEncode(user.Location)}</span>");
}
if (!string.IsNullOrEmpty(user.Website))
{
sb.Append($"<span><a href='{HttpUtility.HtmlAttributeEncode(user.Website)}' ");
sb.Append($"target='_blank' rel='noopener'>{HttpUtility.HtmlEncode(user.Website)}</a></span>");
}
sb.Append($"<span>Joined {user.DateCreated.ToString("MMM yyyy")}</span>");
sb.Append("</div>");
// Session-dependent: show edit buttons only on own profile
bool isOwnProfile = HttpContext.Current.Session["UserId"] != null
&& (int)HttpContext.Current.Session["UserId"] == user.Id;
if (isOwnProfile)
{
sb.Append("<div class='profile-actions'>");
sb.Append("<a href='/EditProfile' class='btn'>Edit Profile</a>");
sb.Append("<a href='/ChangePassword' class='btn'>Change Password</a>");
sb.Append("</div>");
}
sb.Append(PageTemplate.RenderEnd());
Response.ContentType = "text/html";
Response.Write(sb.ToString());
ApiHelper.EndResponse();
}
This profile page demonstrates the full capability of string-based rendering at the PostAcquireRequestState level: it reads session state to determine login status, queries a database for user data, renders conditional content based on whether the viewer owns the profile, handles the 404 case with a proper error page (also rendered through the same template), and produces a complete HTML document with correct SEO metadata — all without touching a single .aspx file.
The Rendering Pipeline at a Glance
Here is the complete flow from HTTP request to HTML response in Pageless Web Forms with C# String-Based HTML Template Rendering:
HTTP Request arrives at IIS
│
▼
IIS parses raw TCP bytes into HttpContext.Current.Request
│
▼
ASP.NET pipeline begins
│
├──► Application_BeginRequest
│ └── Stateless API routes handled here (no session)
│
├──► AuthenticateRequest, AuthorizeRequest, etc.
│
├──► AcquireRequestState
│ └── SessionStateModule loads session data
│
├──► Application_PostAcquireRequestState
│ └── Session-aware page routes handled here
│ │
│ ▼
│ PageHeader generates <head> metadata
│ │
│ ▼
│ PageTemplate.RenderBegin() outputs:
│ <!DOCTYPE html>, <head>, <body>, navbar
│ │
│ ▼
│ Handler method builds page-specific content
│ via StringBuilder (database queries, loops, conditionals)
│ │
│ ▼
│ PageTemplate.RenderEnd() outputs:
│ footer, scripts, </body>, </html>
│ │
│ ▼
│ Response.Write(sb.ToString())
│ │
│ ▼
│ ApiHelper.EndResponse()
│ └── CompleteRequest() → skip to EndRequest
│
├──► [Page handler execution — SKIPPED]
│
├──► EndRequest (cleanup)
│
▼
HTTP Response sent to browser
The page handler execution step — where conventional Web Forms would instantiate a System.Web.UI.Page subclass, build the control tree, process ViewState, run the full PreInit-through-Render lifecycle, and merge with the master page — is entirely skipped. The request goes from “session available” to “response sent” in a single method call.
Summary
C# String-Based HTML Template Rendering is a technique for building complete HTML pages directly in C# code. In its basic form, it uses string literals and StringBuilder to construct an HTML document and writes it to the response stream with Response.Write. In its advanced form, reusable classes — PageHeader for <head> metadata and PageTemplate for the shared page shell — replace the master page and content placeholders of conventional Web Forms.
The technique is framework-independent in concept — any server-side language can build HTML strings. In ASP.NET Web Forms, it becomes architecturally significant when combined with pipeline interception at Application_BeginRequest or Application_PostAcquireRequestState in Global.asax.cs, because this combination eliminates the need for .aspx files, master pages, and the entire Web Forms page lifecycle. This combination is what defines Pageless Web Forms Architecture.
The two components — pipeline interception (where to catch the request) and string-based rendering (how to build the response) — are independent techniques that work together. Pipeline interception is covered in the companion article “Pageless Web Forms Architecture — Session State Without the Page Lifecycle.” This article covers the rendering side: how to construct complete, production-quality HTML pages using nothing but C# strings, StringBuilder, and Response.Write.
Featured Cover Image: Photo by Jonathan Borba: https://www.pexels.com/photo/creative-woman-drawing-picture-with-pencil-6834796/
