Master Pages in ASP.NET Web Forms provide a consistent shell across multiple pages. In BWFC migrations, there are now two deliberate targets:
- Native Blazor layouts — Best for simple single-body shells or after post-migration cleanup
- BWFC MasterPage shell architecture — Best for first-pass migrations, named regions, placeholder defaults, and minimal rewrite
The webforms-to-blazor CLI tool now favors the BWFC shell architecture first for master/content migrations. That keeps MasterPage, ContentPlaceHolder, and Content relationships recognizable while the runtime behaves more like a Blazor layout host with named sections under the hood.
Original Microsoft documentation: https://docs.microsoft.com/en-us/dotnet/api/system.web.ui.masterpage
For migrated master pages, use this architecture first:
- Put the shared shell markup in
<MasterPage>with explicit<Head>and<ChildContent>. - Keep each named region as a
<ContentPlaceHolder ID="...">, including any default content. - Put page-specific overrides in
<Content>blocks grouped under<ChildComponents>. - Refactor to native
@layout+@Bodylater only when the page really behaves like a single-slot Blazor layout.
This strategy keeps the migration-facing tags close to Web Forms while aligning the runtime with a Blazor-style shell host and named sections.
The webforms-to-blazor CLI tool automates the first-pass master-page conversion. The current strategy is to produce a runnable BWFC shell contract instead of forcing every master page directly into @layout / @Body.
The tool performs these transformations automatically:
| Web Forms | Migration Output | Notes |
|---|---|---|
<%@ Master Language="C#" %> |
.razor shell component using <MasterPage> |
Runnable BWFC shell, not an immediate @layout conversion |
.Master file → .razor file |
Shell component file | Preserves master/content relationships |
<form runat="server"> |
Server form wrapper removed | Inner shell content is preserved |
<asp:ContentPlaceHolder ID="X" /> |
<ContentPlaceHolder ID="X" /> |
Named regions and defaults stay intact |
<head runat="server"> content |
<Head> inside <MasterPage> |
Bridges to Blazor HeadContent behavior |
CSS <link href="~/..." /> |
<link href="/..." /> |
Paths rewritten to absolute (root-relative) |
| Child shell body | <ChildContent> ... @ChildContent </ChildContent> |
The shell renders its own structure, then nested child content |
Child pages with <%@ Page MasterPageFile="..." %> |
Remain attached to the generated shell contract | Later transforms can normalize page sections further |
Input - Web Forms Master Page (Site.master):
<%@ Master Language="C#" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><%: Page.Title %> - My Site</title>
<link href="~/css/site.css" rel="stylesheet" />
</head>
<body>
<form runat="server">
<header>Header</header>
<main>
<asp:ContentPlaceHolder ID="MainContent" runat="server" />
</main>
<footer>Footer</footer>
</form>
</body>
</html>Input - Web Forms Child Page (Products.aspx):
<%@ Page Title="Products" MasterPageFile="~/Site.Master" %>
<asp:Content ContentPlaceHolderID="MainContent" runat="server">
<h1>Products</h1>
</asp:Content>Output - BWFC Shell Component (Site.razor):
<MasterPage>
<Head>
<title>My Site</title>
<link href="/css/site.css" rel="stylesheet" />
</Head>
<ChildContent>
<header>Header</header>
<main>
<ContentPlaceHolder ID="MainContent" />
</main>
<footer>Footer</footer>
@ChildContent
</ChildContent>
</MasterPage>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}Recommended child-page shape after page normalization:
<SiteShell>
<ChildComponents>
<Content ContentPlaceHolderID="MainContent">
<PageTitle>Products - My Site</PageTitle>
<h1>Products</h1>
</Content>
</ChildComponents>
</SiteShell>The CLI's first responsibility is to produce a runnable shell. Converting that shell all the way down to native @layout / @Body is a later cleanup step when the master page has effectively become a single-slot layout.
# Install the global tool
dotnet tool install -g Fritz.WebFormsToBlazor
# Run migration
webforms-to-blazor migrate --input ./MyWebFormsProject --output ./MyBlazorProjectThe tool automatically:
- Converts
.masterfiles to runnable BWFC shell components - Preserves named
ContentPlaceHolderregions and their default content - Lifts
<head runat="server">content into the shell's<Head>block - Removes server-form wrappers from the shell
- Creates a complete .NET 10 Blazor SSR project scaffold
See the webforms-to-blazor CLI Documentation for full details.
In Web Forms, a MasterPage defines the template for your pages:
<!-- Site.Master -->
<%@ Master Language="C#" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><%: Page.Title %></title>
</head>
<body>
<form runat="server">
<div class="header">
<h1>My Website</h1>
</div>
<asp:ContentPlaceHolder ID="MainContent" runat="server">
<p>Default content if page doesn't override</p>
</asp:ContentPlaceHolder>
<div class="footer">
<p>© 2024 My Company</p>
</div>
</form>
</body>
</html>Child pages reference the master page:
<!-- MyPage.aspx -->
<%@ Page Title="Home" Language="C#" MasterPageFile="~/Site.Master" %>
<asp:Content ID="Content1" ContentPlaceHolderID="MainContent" runat="server">
<h2>Welcome to my page!</h2>
<p>This is the content.</p>
</asp:Content>In Blazor, layouts are components that inherit from LayoutComponentBase:
<!-- MainLayout.razor -->
@inherits LayoutComponentBase
<div class="header">
<h1>My Website</h1>
</div>
@Body
<div class="footer">
<p>© 2024 My Company</p>
</div>Pages use the @layout directive:
<!-- MyPage.razor -->
@page "/"
@layout MainLayout
<h2>Welcome to my page!</h2>
<p>This is the content.</p>While the CLI tool is the recommended approach, the BlazorWebFormsComponents library provides MasterPage, Content, and ContentPlaceHolder components for gradual or migration-first master-page shells. This approach is useful when:
- You need to migrate incrementally without using the CLI tool
- You want to keep Web Forms-style markup temporarily while refactoring other parts
- You're learning Blazor and want to maintain familiar patterns initially
!!! warning BWFC components are for migration stepping stones only. For new Blazor development, use native layouts. BWFC components add complexity with no functional benefit once fully migrated.
<!-- SiteShell.razor -->
@using BlazorWebFormsComponents
<MasterPage>
<Head>
<link href="/css/site.css" rel="stylesheet" />
</Head>
<ChildContent>
<div class="header">
<h1>My Website</h1>
</div>
<main>
<ContentPlaceHolder ID="MainContent">
<p>Default content goes here</p>
</ContentPlaceHolder>
</main>
<aside>
<ContentPlaceHolder ID="Sidebar">
<p>Default sidebar content</p>
</ContentPlaceHolder>
</aside>
</ChildContent>
</MasterPage><!-- Products.razor -->
<SiteShell>
<ChildComponents>
<Content ContentPlaceHolderID="MainContent">
<h1>Products</h1>
<p>Page body from the migrated child page.</p>
</Content>
<Content ContentPlaceHolderID="Sidebar">
<p>Page-specific sidebar links.</p>
</Content>
</ChildComponents>
</SiteShell>This is the preferred migration architecture because the shell owns structure and defaults, while the page contributes only named section overrides.
The MasterPage component includes a Head parameter that automatically wraps content in a <HeadContent> component, providing a bridge between Web Forms' <head runat="server"> and Blazor's HeadContent. You can include <title> elements directly in the Head content:
<!-- SiteMasterPage.razor -->
@using BlazorWebFormsComponents
<MasterPage>
<Head>
<!-- This content is automatically wrapped in HeadContent -->
<title>My Website - Home</title>
<link href="css/site.css" rel="stylesheet" />
<meta name="description" content="My website" />
</Head>
<ChildContent>
<div class="header">
<h1>My Website</h1>
</div>
<ContentPlaceHolder ID="MainContent">
<p>Default content</p>
</ContentPlaceHolder>
<div class="footer">
<p>© @DateTime.Now.Year My Company</p>
</div>
</ChildContent>
</MasterPage>How it works:
Head Parameter:
- Content placed in the
<Head>parameter is automatically rendered inside a<HeadContent>component - You can include
<title>elements directly in the Head content and they will work correctly - This allows you to maintain Web Forms-style head content definition in the MasterPage
- The content will be injected into the document's
<head>section via the<HeadOutlet>in App.razor - This bridges the gap between Web Forms'
<head runat="server">and Blazor's approach
Migration from Web Forms:
Before (Web Forms Site.Master):
<%@ Master Language="C#" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><%: Page.Title %> - My Site</title>
<link href="css/site.css" rel="stylesheet" />
</head>
<body>
<asp:ContentPlaceHolder ID="MainContent" runat="server">
</asp:ContentPlaceHolder>
</body>
</html>After (Blazor MasterPage):
<MasterPage>
<Head>
<title>@pageTitle - My Site</title>
<link href="css/site.css" rel="stylesheet" />
</Head>
<ChildContent>
<ContentPlaceHolder ID="MainContent">
</ContentPlaceHolder>
</ChildContent>
</MasterPage>
@code {
[Parameter] public string pageTitle { get; set; } = "Home";
}Important: While this parameter provides a convenient migration path, for new Blazor development, it's recommended to use <PageTitle> and <HeadContent> directly in your pages and layouts rather than centralizing all head content in a master page.
Blazor's HeadContent components are additive - multiple <HeadContent> components throughout your component hierarchy all contribute to the final <head> section. This is important to understand when using the MasterPage's Head parameter.
When you have HeadContent at multiple levels:
<!-- MasterPage -->
<MasterPage>
<Head>
<!-- HeadContent #1: From MasterPage -->
<link href="css/site.css" rel="stylesheet" />
</Head>
<ChildContent>
<!-- Your layout here -->
</ChildContent>
</MasterPage>
<!-- Layout (if using one) -->
@inherits LayoutComponentBase
<HeadContent>
<!-- HeadContent #2: From Layout -->
<link href="css/layout.css" rel="stylesheet" />
</HeadContent>
@Body
<!-- Page -->
@page "/mypage"
<HeadContent>
<!-- HeadContent #3: From Page -->
<link href="css/page.css" rel="stylesheet" />
</HeadContent>
<!-- Component within Page -->
<HeadContent>
<!-- HeadContent #4: From Component -->
<link href="css/component.css" rel="stylesheet" />
</HeadContent>Result: All four HeadContent elements are collected by Blazor's <HeadOutlet> and rendered in the document's <head> section:
<head>
<!-- Static content from App.razor -->
<meta charset="utf-8" />
<!-- All HeadContent components combined -->
<link href="css/site.css" rel="stylesheet" /> <!-- From MasterPage.Head -->
<link href="css/layout.css" rel="stylesheet" /> <!-- From Layout -->
<link href="css/page.css" rel="stylesheet" /> <!-- From Page -->
<link href="css/component.css" rel="stylesheet" /> <!-- From Component -->
</head>-
Separation of Concerns - Each level can manage its own head content:
- MasterPage/Layout: Site-wide styles and meta tags
- Page: Page-specific SEO and styles
- Components: Component-specific resources
-
No Conflicts - Unlike Web Forms where the MasterPage owns the entire
<head>, Blazor allows each level to contribute -
Flexibility - Pages and components can add head content without modifying parent components
When migrating from Web Forms:
Before (Web Forms):
<!-- Site.Master - owns entire <head> -->
<head runat="server">
<link href="css/site.css" rel="stylesheet" />
<asp:ContentPlaceHolder ID="HeadContent" runat="server">
</asp:ContentPlaceHolder>
</head>
<!-- MyPage.aspx - can only add via ContentPlaceHolder -->
<asp:Content ContentPlaceHolderID="HeadContent" runat="server">
<link href="css/page.css" rel="stylesheet" />
</asp:Content>After (Blazor with MasterPage.Head):
<!-- MasterPage -->
<MasterPage>
<Head>
<link href="css/site.css" rel="stylesheet" />
</Head>
<ChildContent>...</ChildContent>
</MasterPage>
<!-- MyPage.razor - adds its own HeadContent -->
@page "/mypage"
<HeadContent>
<link href="css/page.css" rel="stylesheet" />
</HeadContent>Key Difference: In Blazor, both the MasterPage's Head parameter and the page's <HeadContent> work together additively. You don't need a ContentPlaceHolder in the head - pages can directly add HeadContent.
- Use MasterPage.Head for site-wide content - CSS frameworks, global meta tags, fonts
- Use page-level HeadContent for specifics - Page titles, descriptions, page-specific styles
- Avoid duplication - Since all HeadContent is combined, be mindful of adding the same resource multiple times
- Consider order - HeadContent from parent components renders before child components
<!-- App.razor -->
<head>
<meta charset="utf-8" />
<HeadOutlet /> <!-- Collects all HeadContent -->
</head>
<!-- SiteMasterPage.razor -->
<MasterPage>
<Head>
<!-- Site-wide resources -->
<link href="css/bootstrap.min.css" rel="stylesheet" />
<link href="css/site.css" rel="stylesheet" />
</Head>
<ChildContent>@ChildContent</ChildContent>
</MasterPage>
<!-- Products.razor -->
@page "/products"
<PageTitle>Products - My Store</PageTitle>
<HeadContent>
<!-- Page-specific resources -->
<link href="css/products.css" rel="stylesheet" />
<meta name="description" content="Browse our products" />
</HeadContent>
<h1>Products</h1>
<ProductCard /> <!-- Component with its own HeadContent -->
<!-- ProductCard.razor (component) -->
<HeadContent>
<!-- Component-specific resources (if needed) -->
<link href="css/product-card.css" rel="stylesheet" />
</HeadContent>
<div class="product-card">...</div>Final HTML head:
<head>
<meta charset="utf-8" />
<title>Products - My Store</title>
<link href="css/bootstrap.min.css" rel="stylesheet" />
<link href="css/site.css" rel="stylesheet" />
<link href="css/products.css" rel="stylesheet" />
<meta name="description" content="Browse our products" />
<link href="css/product-card.css" rel="stylesheet" />
</head>While the components support Web Forms-style syntax, in practice you should use Blazor's native layout system for new development. These components are primarily useful for migration scenarios.
- ChildContent - the template content containing the layout structure and ContentPlaceHolder controls
- Visible - controls visibility of the master page
- Enabled - inherited from BaseWebFormsComponent
- CssClass, BackColor, ForeColor - styling properties
- ID - identifies the placeholder for matching with Content controls
- ChildContent - default content displayed if no Content control provides a replacement
- Visible - controls visibility of the content area
- All styling properties from BaseWebFormsComponent
- ContentPlaceHolderID - specifies which ContentPlaceHolder this content is for
- ChildContent - the content to inject into the ContentPlaceHolder
- Visible - controls visibility
Use the webforms-to-blazor CLI tool for fastest migration. This converts your entire project automatically:
dotnet tool install -g Fritz.WebFormsToBlazor
webforms-to-blazor migrate --input ./MyWebFormsProject --output ./MyBlazorProjectAdvantages:
- Converts entire solution in seconds
- No manual transformation needed
- Generates complete Blazor project with proper scaffolding
- Handles complex nested master pages automatically
- See full transform details in CLI Transform Reference
Recommended for: Most organizations migrating existing applications
Convert master pages to native Blazor layouts manually:
Before (Web Forms MasterPage):
<%@ Master Language="C#" %>
<asp:ContentPlaceHolder ID="MainContent" runat="server">
</asp:ContentPlaceHolder>After (Blazor Layout):
@inherits LayoutComponentBase
@BodyBefore (Web Forms Page):
<%@ Page MasterPageFile="~/Site.Master" %>
<asp:Content ContentPlaceHolderID="MainContent" runat="server">
<h1>Hello</h1>
</asp:Content>After (Blazor Page):
@page "/hello"
@layout MainLayout
<h1>Hello</h1>Advantages:
- Full control over every change
- Learn Blazor layout patterns thoroughly
- Leverage native layout features immediately
Recommended for: Developers learning Blazor or apps with custom master page logic
Use BWFC's MasterPage, Content, and ContentPlaceHolder components as a temporary stepping stone:
- Convert your
.Masterfiles to.razorcomponents - Replace
<%@ Master %>with<MasterPage>wrapper - Convert
<asp:ContentPlaceHolder>to<ContentPlaceHolder> - Update child pages to use
<Content>components - Then gradually refactor to native Blazor layouts when ready
This approach allows you to:
- Keep your existing markup structure temporarily
- Migrate other parts of the application while layouts stabilize
- Learn Blazor incrementally
- Test functionality before refactoring to native layouts
BWFC Control Preservation:
<MasterPage>— Emulates Web Forms master page container<ContentPlaceHolder ID="X">— Defines replaceable regions<Content ContentPlaceHolderID="X">— Provides content for placeholders in pages
Example - Gradual Migration:
<!-- SiteMasterPage.razor - using BWFC temporarily -->
<MasterPage>
<ContentPlaceHolder ID="MainContent">
<p>Default content</p>
</ContentPlaceHolder>
</MasterPage>
<!-- Products.razor - using BWFC temporarily -->
<Content ContentPlaceHolderID="MainContent">
<h1>Products</h1>
</Content>Then later refactor to:
<!-- MainLayout.razor - native Blazor -->
@inherits LayoutComponentBase
@Body
<!-- Products.razor - updated -->
@page "/products"
@layout MainLayout
<h1>Products</h1>Recommended for: Developers learning Blazor who want to preserve Web Forms syntax temporarily
The cleanest migration path is to convert MasterPages directly to Blazor layouts:
Web Forms supports nested master pages. In Blazor, use nested layouts:
<!-- BaseLayout.razor -->
@inherits LayoutComponentBase
<div class="wrapper">
@Body
</div>
<!-- SiteLayout.razor -->
@inherits LayoutComponentBase
@layout BaseLayout
<header>Header</header>
@Body
<footer>Footer</footer>Recommended for: Applications using master page hierarchies
| Feature | Web Forms | Blazor |
|---|---|---|
| File Extension | .Master |
.razor |
| Directive | <%@ Master %> |
@inherits LayoutComponentBase |
| Content Area | <asp:ContentPlaceHolder> |
@Body or @RenderSection |
| Page Declaration | <%@ Page MasterPageFile="~/Site.Master" %> |
@layout MainLayout |
| Multiple Sections | Multiple ContentPlaceHolders | @RenderSection for named sections |
| Default Content | Inside ContentPlaceHolder tags | Not typically used; define in layout |
| Nesting | MasterPageFile on master page |
@layout directive in layout component |
| HTML Head Management | <head runat="server"> in MasterPage |
<HeadOutlet> in App.razor with <HeadContent> components |
| Page Title | <%: Page.Title %> or Page.Title property |
<PageTitle> component |
| Scripts/Styles | In <head> or ContentPlaceHolders |
<HeadContent> component or <script>/<link> in App.razor |
| CSS Links from Master | In master page <head> |
Migrated to App.razor <head> with path rewriting |
When master pages are converted to layouts, CSS <link> elements must be moved from the layout to App.razor because:
- Blazor layouts don't control the HTML structure — Only the
<body>content App.razordefines the entire HTML document including<head>- Path rewriting is required — Relative paths become root-relative
Web Forms Master Page:
<head runat="server">
<link href="~/CSS/bootstrap.css" rel="stylesheet" />
<link href="~/CSS/site.css" rel="stylesheet" />
</head>Output - App.razor (after CLI tool):
<head>
<link rel="stylesheet" href="/CSS/bootstrap.css" />
<link rel="stylesheet" href="/CSS/site.css" />
<HeadOutlet />
</head>Key changes:
href="~/..."→href="/..."(tilde to root-relative path)- Location: Master page
<head>→App.razor<head> - This happens automatically with the CLI tool — no manual intervention needed
The CLI tool automatically rewrites path patterns:
| Input Pattern | Output Pattern | Example |
|---|---|---|
href="~/CSS/style.css" |
href="/CSS/style.css" |
Tilde prefix removed, becomes root-relative |
href="CSS/style.css" |
href="/CSS/style.css" |
Relative path converted to root-relative |
src="~/Scripts/app.js" |
src="/Scripts/app.js" |
Works for <script> tags too |
href="https://..." |
href="https://..." |
Absolute URLs unchanged |
href="http://..." |
href="http://..." |
Absolute URLs unchanged |
Why rewriting is necessary: In Blazor, <head> content isn't relative to the current page URL — paths are always resolved from the site root, so they must be root-relative (starting with /).
This is a critical difference between Web Forms MasterPages and Blazor layouts.
In Web Forms, the MasterPage controls the entire HTML document including the <head> section:
<!-- Site.Master -->
<%@ Master Language="C#" %>
<!DOCTYPE html>
<html>
<head runat="server">
<meta charset="utf-8" />
<title><%: Page.Title %> - My Site</title>
<!-- Scripts can be added here -->
<asp:PlaceHolder runat="server">
<%: Scripts.Render("~/bundles/modernizr") %>
</asp:PlaceHolder>
<!-- Stylesheets -->
<link href="~/Content/site.css" rel="stylesheet" />
<!-- Child pages can add to head -->
<asp:ContentPlaceHolder ID="HeadContent" runat="server">
</asp:ContentPlaceHolder>
</head>
<body>
<!-- Body content -->
</body>
</html>Child pages can add to the head:
<%@ Page Title="My Page" MasterPageFile="~/Site.Master" %>
<asp:Content ContentPlaceHolderID="HeadContent" runat="server">
<link href="/css/custom.css" rel="stylesheet" />
<script src="/js/custom.js"></script>
</asp:Content>In Blazor, the HTML structure is defined in App.razor (or _Host.cshtml in older versions), not in layouts:
App.razor (root component):
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<!-- Static stylesheets -->
<link rel="stylesheet" href="css/bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="css/site.css" />
<!-- HeadOutlet renders PageTitle and HeadContent from pages/layouts -->
<HeadOutlet />
</head>
<body>
<!-- Routes component renders pages with their layouts -->
<Routes />
<!-- Scripts at bottom of body -->
<script src="_framework/blazor.web.js"></script>
<script src="js/site.js"></script>
</body>
</html>Individual Pages can add content to <head>:
@page "/mypage"
<PageTitle>My Page Title</PageTitle>
<HeadContent>
<link href="css/custom.css" rel="stylesheet" />
<script src="js/custom.js"></script>
<meta name="description" content="My page description" />
</HeadContent>
<h1>Page Content</h1>Layouts can also add head content:
@inherits LayoutComponentBase
<HeadContent>
<!-- Common head content for all pages using this layout -->
<link href="css/layout-specific.css" rel="stylesheet" />
</HeadContent>
<div class="page">
@Body
</div>When migrating from Web Forms MasterPages to Blazor:
Content that applies to all pages should move from MasterPage to App.razor:
Before (Site.Master):
<head runat="server">
<link href="~/Content/bootstrap.css" rel="stylesheet" />
<link href="~/Content/site.css" rel="stylesheet" />
<script src="~/Scripts/jquery.js"></script>
</head>After (App.razor):
<head>
<link rel="stylesheet" href="css/bootstrap.css" />
<link rel="stylesheet" href="css/site.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="js/jquery.js"></script>
</body>If your MasterPage has a ContentPlaceHolder in the <head>:
Before (Site.Master):
<head runat="server">
<asp:ContentPlaceHolder ID="HeadContent" runat="server">
</asp:ContentPlaceHolder>
</head>Before (MyPage.aspx):
<asp:Content ContentPlaceHolderID="HeadContent" runat="server">
<link href="/css/page-specific.css" rel="stylesheet" />
</asp:Content>After (MyPage.razor):
@page "/mypage"
<HeadContent>
<link href="css/page-specific.css" rel="stylesheet" />
</HeadContent>Before (Site.Master):
<head runat="server">
<title><%: Page.Title %> - My Site</title>
</head>Before (MyPage.aspx):
<%@ Page Title="Home" MasterPageFile="~/Site.Master" %>After (MyPage.razor):
@page "/"
<PageTitle>Home - My Site</PageTitle>Option A: Global Scripts in App.razor (Recommended)
<!-- App.razor -->
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
<script src="js/jquery.js"></script>
<script src="js/site.js"></script>
</body>Option B: Page-Specific Scripts with HeadContent
<!-- MyPage.razor -->
<HeadContent>
<script src="js/page-specific.js"></script>
</HeadContent>Option C: Dynamic Script Loading with IJSRuntime
@inject IJSRuntime JS
@code {
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JS.InvokeVoidAsync("loadScript", "js/dynamic.js");
}
}
}-
Layouts Don't Control HTML Structure - In Blazor, layouts only control the body content. The
<html>,<head>, and<body>tags are defined inApp.razor, not layouts. -
No
<head runat="server">- Blazor doesn't have server-side head manipulation like Web Forms. Use<HeadContent>components instead. -
Script Placement - Scripts are typically placed at the bottom of
App.razorrather than in layouts or individual pages for better performance. -
HeadOutlet is Required - You must include
<HeadOutlet />in yourApp.razorfor<PageTitle>and<HeadContent>to work.
Before - Web Forms:
Site.Master:
<%@ Master Language="C#" %>
<!DOCTYPE html>
<html>
<head runat="server">
<title><%: Page.Title %> - Contoso</title>
<link href="~/Content/bootstrap.css" rel="stylesheet" />
<link href="~/Content/site.css" rel="stylesheet" />
<asp:ContentPlaceHolder ID="HeadContent" runat="server">
</asp:ContentPlaceHolder>
</head>
<body>
<asp:ContentPlaceHolder ID="MainContent" runat="server">
</asp:ContentPlaceHolder>
<script src="~/Scripts/jquery.js"></script>
<script src="~/Scripts/site.js"></script>
</body>
</html>Products.aspx:
<%@ Page Title="Products" MasterPageFile="~/Site.Master" %>
<asp:Content ContentPlaceHolderID="HeadContent" runat="server">
<link href="/Content/products.css" rel="stylesheet" />
<meta name="description" content="Our products" />
</asp:Content>
<asp:Content ContentPlaceHolderID="MainContent" runat="server">
<h1>Our Products</h1>
</asp:Content>After - Blazor:
App.razor:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="css/bootstrap.css" />
<link rel="stylesheet" href="css/site.css" />
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
<script src="js/jquery.js"></script>
<script src="js/site.js"></script>
</body>
</html>MainLayout.razor:
@inherits LayoutComponentBase
<div class="page">
@Body
</div>Products.razor:
@page "/products"
<PageTitle>Products - Contoso</PageTitle>
<HeadContent>
<link href="css/products.css" rel="stylesheet" />
<meta name="description" content="Our products" />
</HeadContent>
<h1>Our Products</h1>Blazor supports named sections similar to multiple ContentPlaceHolders:
Layout:
@inherits LayoutComponentBase
<header>
@RenderSection("Header", required: false)
</header>
<main>
@Body
</main>
<aside>
@RenderSection("Sidebar", required: false)
</aside>Page:
@page "/mypage"
@section Header {
<h1>Page Title</h1>
}
@section Sidebar {
<p>Sidebar content</p>
}
<p>Main content here</p>- Use the CLI tool — Let it handle all master page transformations automatically
- Review generated App.razor — Verify CSS paths are correct
- Test in browser — Ensure all styles load correctly (check DevTools Network tab)
- Then refactor gradually — Once layouts work, add layouts incrementally via
@layoutdirective
- Start with the CLI tool if possible — It handles 90% of the work automatically
- Preserve control IDs — Use
@key="@item.Id"for data-bound content - Use HeadContent for page-specific styles — Keep App.razor clean with only global styles
- Leverage components — Extract shared layout sections into reusable components
- Test before moving on — Verify each page renders correctly before next migration step
- Put global styles in App.razor — CSS frameworks, normalize.css, site-wide styles
- Put page-specific styles in HeadContent — Page-only or section-only styles
- Avoid duplication — Each resource should load exactly once
- Remember loading order — HeadContent from parent components renders before child components
- ❌ Don't: Think of layouts as replacing master pages 1:1 — they're simpler
- ❌ Don't: Put all head content in one location — HeadContent is additive
- ❌ Don't: Use nested layouts for simple structure variations — use cascading parameters instead
- ✅ Do: Move static resources to App.razor
- ✅ Do: Let pages/components define their own head content via HeadContent
- ✅ Do: Use layouts for overall page structure only
Before - Web Forms:
Site.Master:
<%@ Master Language="C#" %>
<html>
<body>
<div class="header">Site Header</div>
<asp:ContentPlaceHolder ID="MainContent" runat="server">
</asp:ContentPlaceHolder>
<div class="footer">Footer</div>
</body>
</html>Default.aspx:
<%@ Page Title="Home" MasterPageFile="~/Site.Master" %>
<asp:Content ContentPlaceHolderID="MainContent" runat="server">
<h1>Welcome</h1>
</asp:Content>After - Blazor:
MainLayout.razor:
@inherits LayoutComponentBase
<div class="header">Site Header</div>
<main>
@Body
</main>
<div class="footer">Footer</div>Index.razor:
@page "/"
@layout MainLayout
<PageTitle>Home</PageTitle>
<h1>Welcome</h1>The following MasterPage features don't have direct equivalents in Blazor:
<head runat="server">- Blazor layouts don't control the HTML head. Use<HeadContent>components in pages/layouts, and define static head content inApp.razor- Page.Title property - Use
<PageTitle>component instead - ContentPlaceHolder in
<head>- Use<HeadContent>component in pages instead - Master property - Not needed; layouts are applied via directive
- MasterPageFile in web.config - Use
@layoutdirective or configure inApp.razor - MasterType directive - Not applicable in Blazor
- FindControl across master/content - Use component references and cascading parameters
- Server-side script registration - Use
<HeadContent>with<script>tags or IJSRuntime for dynamic loading
Master page migration to Blazor is now fully automated and reliable thanks to the webforms-to-blazor CLI tool. The recommended migration path is:
- Run the CLI tool — Converts your entire application in seconds
- Test the output — Verify all styles load and pages render correctly
- Gradually refactor — Convert layouts to use more Blazor-idiomatic patterns over time
For developers who prefer manual control or gradual migration, the BlazorWebFormsComponents library provides MasterPage, Content, and ContentPlaceHolder components as a stepping stone to native Blazor layouts.
Regardless of which path you choose, understand that Blazor layouts are simpler and more flexible than Web Forms master pages. The additive nature of <HeadContent> and the separation between App.razor (document structure) and layouts (body content) make Blazor's layout system more modular and maintainable.
Start with the CLI tool. If you need custom logic, migrate manually. Never use BWFC components for new development — only as a temporary migration aid.
- webforms-to-blazor CLI Tool — Automated master page migration
- Blazor Layout Documentation — Official Microsoft docs
- ASP.NET Core Blazor Routing — Routing and layout selection
- Web Forms to Blazor Migration Guide — Full migration methodology
- Three-Layer Migration Methodology — Complete migration architecture