Master Pages, User Controls and Navigation
Before you start
You need: state management (Article 04).
Time: about 40 minutes, plus the practice.
Learning objective
Change a shared layout or reusable control without breaking the pages that use it, and move data between pages safely.
Topics
- Master pages and content placeholders
- Reaching master page members from a content page
- Nested masters
- User controls (
.ascx) and their properties and events Response.RedirectversusServer.Transfer- Cross-page posting and
PreviousPage ~/paths andResolveUrl
Master pages
A master page is the shared shell — header, navigation, footer — that content pages render inside. It is the ancestor of Razor's _Layout.cshtml.
<%@ Master Language="VB" CodeBehind="Site.master.vb" Inherits="StudentPortal.SiteMaster" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><asp:ContentPlaceHolder ID="TitleContent" runat="server" /></title>
<asp:ContentPlaceHolder ID="HeadContent" runat="server" />
</head>
<body>
<form id="form1" runat="server">
<header>
<span>NexCoding Academy</span>
<asp:Label ID="lblUserName" runat="server" />
</header>
<nav>
<a href='<%= ResolveUrl("~/Students/List.aspx") %>'>Students</a>
<a href='<%= ResolveUrl("~/Fees/Collect.aspx") %>'>Fees</a>
</nav>
<main>
<asp:ContentPlaceHolder ID="MainContent" runat="server" />
</main>
<footer>© NexCoding Academy</footer>
</form>
</body>
</html>
The content page fills the placeholders and contains nothing else:
<%@ Page Language="VB" MasterPageFile="~/Site.master"
CodeBehind="List.aspx.vb" Inherits="StudentPortal.Students.List" Title="Students" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h1>Students</h1>
<asp:TextBox ID="txtSearch" runat="server" />
<asp:Button ID="btnSearch" runat="server" Text="Search" />
<asp:GridView ID="grdStudents" runat="server" AutoGenerateColumns="False" />
</asp:Content>
Three rules:
- A content page can contain only
<asp:Content>elements. Any markup outside one is a compile error. - The
<form runat="server">lives on the master, not the content page. A content page must not add its own. - A placeholder the content page does not fill renders the master's default content, if any.
The master can also be set in code, but only in PreInit — it is applied before Init:
Protected Sub Page_PreInit(sender As Object, e As EventArgs) Handles Me.PreInit
If Request.QueryString("print") = "1" Then
MasterPageFile = "~/Print.master"
End If
End Sub
Lifecycle order with a master page
This surprises people: the content page's events fire before the master's.
Content Page_Init → Master Page_Init
Content Page_Load → Master Page_Load
Control events
Master Page_PreRender → Content Page_PreRender
So a value the master sets in its Page_Load is not yet set when the content page's Page_Load runs. Code that reads a master property from a content page's Page_Load and gets Nothing is hitting this ordering. Move the read to PreRender, or have the master set the value in Init.
Reaching the master from a content page
Master is typed as the base MasterPage, so its members are not visible. Two ways round it.
Typed reference — declare the master's type on the content page:
<%@ MasterType VirtualPath="~/Site.master" %>
' Master is now strongly typed
Master.SetPageHeading("Student Records")
' Site.master.vb
Public Sub SetPageHeading(text As String)
lblHeading.Text = text
End Sub
FindControl — works without the directive, and is the fragile option:
Dim heading As Label = TryCast(Master.FindControl("lblHeading"), Label)
If heading IsNot Nothing Then
heading.Text = "Student Records"
End If
Prefer MasterType and a public method. FindControl binds to a control name in a string: renaming the control in the master breaks every page silently, and nothing fails until run time.
Expose behaviour, not controls. A public SetPageHeading method survives a redesign of the master's markup; a public Label field does not.
Nested master pages
<%@ Master Language="VB" MasterPageFile="~/Site.master"
CodeBehind="Admin.master.vb" Inherits="StudentPortal.AdminMaster" %>
<asp:Content ID="c1" ContentPlaceHolderID="MainContent" runat="server">
<div class="admin-shell">
<asp:ContentPlaceHolder ID="AdminContent" runat="server" />
</div>
</asp:Content>
A child master fills its parent's placeholder and exposes new ones. Each level adds another naming-container prefix to every rendered id, which is why ids in deeply nested pages get long. Two levels is manageable; three makes FindControl and JavaScript painful.
User controls
A .ascx is a reusable fragment with markup and code-behind. It has no URL and cannot be requested directly.
<%@ Control Language="VB" CodeBehind="StudentCard.ascx.vb"
Inherits="StudentPortal.Controls.StudentCard" %>
<div class="student-card">
<asp:Label ID="lblName" runat="server" />
<asp:Label ID="lblRoll" runat="server" />
<asp:Button ID="btnSelect" runat="server" Text="Select" />
</div>
Public Partial Class StudentCard
Inherits UserControl
Public Event StudentSelected As EventHandler(Of StudentEventArgs)
Public Property StudentPublicId As Guid
Public Sub Bind(student As Student)
StudentPublicId = student.PublicId
lblName.Text = student.Name
lblRoll.Text = student.RollNumber
End Sub
Protected Sub btnSelect_Click(sender As Object, e As EventArgs) Handles btnSelect.Click
RaiseEvent StudentSelected(Me, New StudentEventArgs(StudentPublicId))
End Sub
End Class
Register and use it:
<%@ Register Src="~/Controls/StudentCard.ascx" TagPrefix="uc" TagName="StudentCard" %>
<uc:StudentCard ID="ucCard" runat="server" OnStudentSelected="Card_StudentSelected" />
Protected Sub Card_StudentSelected(sender As Object, e As StudentEventArgs)
Response.Redirect("~/Students/Edit.aspx?publicId=" & e.PublicId.ToString())
End Sub
Raising a custom event is the right way for a control to tell its page something happened. The alternative — the control reaching up to Page and manipulating it directly — couples the control to one page and stops it being reusable.
A user control participates in the full page lifecycle. Its Page_Load runs after the page's, and IsPostBack applies inside it exactly the same way.
<%@ Register %> can be declared once for the whole application:
<system.web>
<pages>
<controls>
<add tagPrefix="uc" src="~/Controls/StudentCard.ascx" tagName="StudentCard" />
</controls>
</pages>
</system.web>
Response.Redirect versus Server.Transfer
' Tells the BROWSER to request a new URL — 302, then a fresh GET
Response.Redirect("~/Students/List.aspx")
' Hands over on the SERVER — the browser never knows
Server.Transfer("~/Students/List.aspx")
Response.Redirect | Server.Transfer | |
|---|---|---|
| Round trips | Two | One |
| Browser URL | Updates | Stays on the old page |
| Bookmarkable | Yes | No |
| Can leave the site | Yes | No |
| Query string | Rebuilt by you | Preserved with preserveForm |
Request.Form on the target | Empty | Carries the original post |
Server.Transfer leaving the URL unchanged is the trap. The user sees Edit.aspx in the address bar while List.aspx is rendered; refreshing re-posts the original form. Use Response.Redirect unless there is a specific reason not to.
The ThreadAbortException
' Default — raises ThreadAbortException to stop the current page
Response.Redirect("~/Login.aspx")
' Better — no exception, but execution CONTINUES
Response.Redirect("~/Login.aspx", False)
Return
The one-argument form aborts the thread, which is caught by any surrounding Catch ex As Exception and logged as an error. Legacy logs full of ThreadAbortException are almost always redirects inside Try blocks, not real failures.
The two-argument form avoids that — but the rest of the method still runs. The Return is mandatory. Without it, code after the redirect executes, which is how a security redirect ends up loading and rendering the very data it was meant to block.
The same applies to Response.End(), which also raises ThreadAbortException.
Cross-page posting
A button can post to a different page:
<asp:Button ID="btnSearch" runat="server" Text="Search"
PostBackUrl="~/Students/Results.aspx" />
On the target:
<%@ PreviousPageType VirtualPath="~/Students/Search.aspx" %>
Protected Sub Page_Load(sender As Object, e As EventArgs) Handles Me.Load
If PreviousPage IsNot Nothing AndAlso PreviousPage.IsCrossPagePostBack Then
LoadStudents(PreviousPage.SearchTerm)
End If
End Sub
' Search.aspx.vb — expose what the other page needs
Public ReadOnly Property SearchTerm As String
Get
Return txtSearch.Text
End Get
End Property
PreviousPage is Nothing on a normal request, on a refresh, and whenever the user arrives by any other route. Always null-check it. Cross-page posting is rare in practice; a query string or session value is usually clearer and survives a refresh.
Paths and ResolveUrl
<!-- Breaks when the page moves to a subfolder -->
<a href="Students/List.aspx">Students</a>
<!-- Breaks when the app is deployed under a virtual directory -->
<a href="/Students/List.aspx">Students</a>
<!-- Correct — ~ resolves to the application root -->
<a href='<%= ResolveUrl("~/Students/List.aspx") %>'>Students</a>
<asp:HyperLink runat="server" NavigateUrl="~/Students/List.aspx" Text="Students" />
~/ means the application root. Server controls understand it directly; plain HTML needs ResolveUrl or ResolveClientUrl.
This matters because applications are frequently deployed under a virtual directory — https://intranet/StudentPortal/ rather than the site root. Every absolute path starting / then points at the wrong place. If links work locally and 404 on the server, this is the first thing to check.
Errors you will hit
| Message | Cause | Fix |
|---|---|---|
Content controls have to be top-level controls in a content page | Markup placed outside a <asp:Content> block | Move it inside |
Cannot find ContentPlaceHolder 'x' | Renamed or removed in the master page | Every content page referencing it must be updated |
FindControl on a master-page control returns Nothing | Naming containers again | Use Master.FindControl |
| Form re-submits when the user presses refresh | Used Server.Transfer | Use Response.Redirect after a successful POST |
| The URL does not change after navigation | Server.Transfer keeps the old URL | Expected — use Redirect if you want the URL to change |
Thread was being aborted | Response.Redirect without False | Response.Redirect(url, False) and return |
Redirect after a successful POST. Without it, a refresh resubmits the form — and for a fee payment screen that means charging a parent twice.
Common mistakes
- Putting markup outside
<asp:Content>on a content page - Adding a second
<form runat="server">on a content page - Reading a master page value from the content page's
Page_Load, before the master has set it Master.FindControl("lblHeading")with a string name, broken silently by a rename- Exposing a control publicly from the master instead of a method
Response.Redirectinside aTry, filling logs withThreadAbortExceptionResponse.Redirect(url, False)with noReturn, so protected code still runsServer.Transferwhere the URL needed to change- Using
PreviousPagewithout a null check - Absolute paths that break under a virtual directory
Practice
On an existing Web Forms application, find the master page and list every member a content page uses from it, noting whether each is reached by MasterType or FindControl. Convert one FindControl usage to a typed public method.
Then find every Response.Redirect in the project and classify each: inside a Try block, using the two-argument form, and whether it is followed by Return. Fix one that is missing its Return and write down what would have executed anyway.
Finally, run the course exercise — pass data through Query String and Session. Build a two-page flow: a search page that redirects to a results page with the term in the query string, and a second version that uses session instead. Then refresh each results page and record what happens. The difference is the argument for choosing one over the other.
You can now
- Change a master page without breaking its content pages
- Build and reuse a user control
- Choose correctly between
Response.RedirectandServer.Transfer - Move data between pages so it survives a refresh
- Reach a control that lives on the master page
Review questions
- Why does the content page's
Page_Loadrun before the master page's? - Why is a public method on the master better than a public control?
- What still executes after
Response.Redirect(url, False)with noReturn? - Why do links that work locally 404 when deployed under a virtual directory?
Next: Validation controls