#
Lab 1: Baseline Razor Pages App + "Fragment First"
#
Overview
Welcome to the first lab of the htmx + ASP.NET Core Workshop! In this lab, you will build the foundation that all subsequent labs depend upon. By the end of this lab, you will have a fully functional Razor Pages application with a clean "Fragment First" architecture—a design pattern that makes htmx integration seamless in later labs.
#
What is "Fragment First"?
"Fragment First" is an architectural approach where you design your UI as composable HTML fragments from the very beginning, before adding any htmx interactivity. Each fragment:
- Has a single responsibility (display a list, render a form, show messages)
- Lives in its own partial view file
- Contains a stable root element with a predictable ID (e.g.,
#task-list,#task-form)
This approach treats partials not as "optimization" or "code organization," but as interfaces for future interactivity. When you later add htmx, each fragment becomes a swappable unit that the server can return independently.
#
Why This Matters
Traditional server-rendered applications return full pages on every interaction. With htmx, you can return just the fragment that changed. But this only works well if your fragments are designed with clear boundaries from the start. The "Fragment First" pattern ensures:
- Predictable targets: Every region has a known ID that htmx can target
- Clean separation: Each partial handles one concern
- Easy testing: You can render and verify fragments in isolation
- Progressive enhancement: The app works without htmx; adding it later is additive
#
Lab Outcomes
By the end of this lab, you will have:
This becomes the "frame" for all htmx work in subsequent labs.
#
Prerequisites
Before starting this lab, ensure you have:
- .NET 8 SDK or later installed (download)
- An IDE or editor: Visual Studio 2022, JetBrains Rider, or VS Code with C# extensions
- A modern web browser with developer tools (Chrome, Edge, or Firefox)
- Basic familiarity with C#, Razor Pages, and HTML
#
Step 1: Create a New Razor Pages Project (5–8 minutes)
We will create the project from scratch using the .NET CLI. This gives you full control over the project structure and ensures everyone starts from the same baseline.
#
1.1 Open Your Terminal
Open a terminal or command prompt and navigate to your preferred working directory.
#
1.2 Create the Project
Run the following commands to create a new Razor Pages web application:
dotnet new webapp -n RazorHtmxWorkshop
cd RazorHtmxWorkshop
```
**What this does:**
- `dotnet new webapp` creates a new ASP.NET Core Razor Pages application
- `-n RazorHtmxWorkshop` names the project "RazorHtmxWorkshop"
- `cd RazorHtmxWorkshop` navigates into the project directory
### 1.3 Verify the Project Structure
Your project should have this structure:
```
RazorHtmxWorkshop/
├── Pages/
│ ├── Shared/
│ │ ├── _Layout.cshtml
│ │ └── _ValidationScriptsPartial.cshtml
│ ├── _ViewImports.cshtml
│ ├── _ViewStart.cshtml
│ ├── Error.cshtml
│ ├── Index.cshtml
│ └── Privacy.cshtml
├── Properties/
│ └── launchSettings.json
├── wwwroot/
│ ├── css/
│ └── lib/
├── appsettings.json
├── appsettings.Development.json
├── Program.cs
└── RazorHtmxWorkshop.csproj
#
1.4 Run the Application
Start the application to verify everything works:
dotnet run
```
You should see output similar to:
```
info: Microsoft.Hosting.Lifetime[14]
Now listening on: http://localhost:5000
info: Microsoft.Hosting.Lifetime[14]
Now listening on: https://localhost:5001
#
1.5 Open in Browser
Open your browser and navigate to the URL shown (e.g., https://localhost:5001). You should see the default ASP.NET Core welcome page.
Keep the browser open—you will refresh frequently throughout this lab.
Tip: If you prefer, you can use
dotnet watch runinstead ofdotnet run. This enables hot reload, automatically rebuilding and refreshing when you save changes.
#
Step 2: Create the Domain Model (10–12 minutes)
Now we will establish a simple domain: Tasks. We are intentionally keeping this minimal so we can focus on UI patterns rather than data complexity.
#
Design Constraints
For this workshop, we follow these constraints:
- Single page focus: One page that will become highly interactive with htmx
- In-memory storage: No database setup required; focus stays on UI patterns
- Fully server-rendered: No htmx yet—that comes in Lab 2
#
2.1 Create the TaskItem Model
Create a new folder called Models in your project root, then create a file named TaskItem.cs:
File: Models/TaskItem.cs
namespace RazorHtmxWorkshop.Models;
/// <summary>
/// Represents a single task in our task management domain.
/// This is intentionally simple—we want to focus on UI patterns, not domain complexity.
/// </summary>
public class TaskItem
{
/// <summary>
/// Unique identifier for the task.
/// </summary>
public int Id { get; set; }
/// <summary>
/// The task's title/description.
/// </summary>
public string Title { get; set; } = "";
/// <summary>
/// Whether the task has been completed.
/// </summary>
public bool IsDone { get; set; }
/// <summary>
/// When the task was created (UTC).
/// </summary>
public DateTime CreatedUtc { get; set; } = DateTime.UtcNow;
}
Why this design:
Id: We need a way to identify individual tasks for future operations (edit, delete)Title: The primary content users will see and interact withIsDone: A simple status flag for visual differentiationCreatedUtc: Useful for sorting (newest first) and displaying creation time
#
2.2 Create the In-Memory Task Store
Create a new folder called Data in your project root, then create a file named InMemoryTaskStore.cs:
File: Data/InMemoryTaskStore.cs
using RazorHtmxWorkshop.Models;
namespace RazorHtmxWorkshop.Data;
/// <summary>
/// A simple in-memory store for TaskItems.
///
/// Design notes:
/// - Static class for simplicity in a workshop context
/// - In production, you would use Entity Framework Core, Dapper, or another data access approach
/// - Data is lost when the application restarts—this is intentional for workshop isolation
/// </summary>
public static class InMemoryTaskStore
{
// Auto-incrementing ID counter
private static int _nextId = 1;
// The actual storage - a simple list
private static readonly List<TaskItem> _tasks = new();
/// <summary>
/// Returns all tasks, ordered by creation date (newest first).
/// Returns a new list to prevent external modification of internal state.
/// </summary>
public static IReadOnlyList<TaskItem> All() => _tasks
.OrderByDescending(t => t.CreatedUtc)
.ToList();
/// <summary>
/// Adds a new task with the given title.
/// Automatically assigns an ID and creation timestamp.
/// </summary>
/// <param name="title">The task title (will be trimmed)</param>
/// <returns>The newly created TaskItem</returns>
public static TaskItem Add(string title)
{
var item = new TaskItem
{
Id = _nextId++,
Title = title.Trim(),
IsDone = false,
CreatedUtc = DateTime.UtcNow
};
_tasks.Add(item);
return item;
}
}
```
**Why an in-memory store:**
- **Zero setup**: No database connection strings, migrations, or external dependencies
- **Fast iteration**: Changes are immediate; no waiting for database operations
- **Workshop isolation**: Each attendee's data is independent; restarting clears everything
- **Pattern focus**: We can concentrate on htmx and Razor Pages patterns, not data access
> **Production Note**: In a real application, you would replace this with a proper data access layer using Entity Framework Core, Dapper, or your preferred ORM. The interface (methods like `All()` and `Add()`) would remain similar.
---
## Step 3: Create the Tasks Page (10–12 minutes)
Now we will create the main Tasks page with its PageModel. This page will host all three fragment regions.
### 3.1 Create the Tasks Folder Structure
Create the following folder structure:
```
Pages/
└── Tasks/
├── Index.cshtml
└── Index.cshtml.cs
You can create these files manually or use your IDE's scaffolding features.
#
3.2 Create the PageModel
File: Pages/Tasks/Index.cshtml.cs
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using RazorHtmxWorkshop.Data;
using RazorHtmxWorkshop.Models;
namespace RazorHtmxWorkshop.Pages.Tasks;
/// <summary>
/// PageModel for the Tasks index page.
///
/// This handles:
/// - Displaying the list of tasks (OnGet)
/// - Creating new tasks (OnPostCreate)
///
/// Design notes:
/// - Tasks property is populated on every request that needs to display the list
/// - Input property uses [BindProperty] for model binding on POST
/// - FlashMessage uses [TempData] to survive redirects
/// </summary>
public class IndexModel : PageModel
{
/// <summary>
/// The list of tasks to display. Populated in handlers that render the page.
/// </summary>
public IReadOnlyList<TaskItem> Tasks { get; private set; } = Array.Empty<TaskItem>();
/// <summary>
/// Input model for creating new tasks. Bound automatically on POST requests.
/// </summary>
[BindProperty]
public NewTaskInput Input { get; set; } = new();
/// <summary>
/// Flash message to display after redirects.
/// TempData survives one redirect, making it perfect for "success" messages.
/// </summary>
[TempData]
public string? FlashMessage { get; set; }
/// <summary>
/// Handles GET requests to /Tasks.
/// Loads all tasks for display.
/// </summary>
public void OnGet()
{
Tasks = InMemoryTaskStore.All();
}
/// <summary>
/// Handles POST requests to /Tasks?handler=Create.
/// Validates input, creates the task, and redirects back to the page.
///
/// The Post-Redirect-Get (PRG) pattern prevents duplicate submissions
/// when users refresh the page after submitting.
/// </summary>
public IActionResult OnPostCreate()
{
// Manual validation (we'll add data annotations in Lab 3)
if (string.IsNullOrWhiteSpace(Input.Title))
{
ModelState.AddModelError(nameof(Input.Title), "Title is required.");
}
// If validation failed, redisplay the page with errors
if (!ModelState.IsValid)
{
Tasks = InMemoryTaskStore.All();
return Page();
}
// Create the task
InMemoryTaskStore.Add(Input.Title);
// Set success message and redirect (PRG pattern)
FlashMessage = "Task added.";
return RedirectToPage();
}
/// <summary>
/// Input model for the create task form.
/// Nested class keeps it close to its usage context.
/// </summary>
public class NewTaskInput
{
public string Title { get; set; } = "";
}
}
Key concepts explained:
#
3.3 Create the Razor Page (Initial Version)
File: Pages/Tasks/Index.cshtml
@page
@model RazorHtmxWorkshop.Pages.Tasks.IndexModel
@{
ViewData["Title"] = "Tasks";
}
<h1>Tasks</h1>
@* Messages region - will become #messages fragment *@
<div id="messages">
@if (!string.IsNullOrWhiteSpace(Model.FlashMessage))
{
<div class="alert alert-success" role="alert">
@Model.FlashMessage
</div>
}
</div>
<div class="row">
@* Form region - will become #task-form fragment *@
<div class="col-md-5">
<h2 class="h5">Add a Task</h2>
<div id="task-form">
<form method="post" asp-page-handler="Create">
<div class="mb-3">
<label class="form-label" for="title">Task Title</label>
<input id="title"
class="form-control"
asp-for="Input.Title"
placeholder="e.g., Add htmx to Razor Pages" />
<span class="text-danger" asp-validation-for="Input.Title"></span>
</div>
<button class="btn btn-primary" type="submit">Add Task</button>
</form>
</div>
</div>
@* List region - will become #task-list fragment *@
<div class="col-md-7">
<h2 class="h5">Current Tasks</h2>
<div id="task-list">
@if (Model.Tasks.Count == 0)
{
<p class="text-muted">No tasks yet.</p>
}
else
{
<ul class="list-group">
@foreach (var task in Model.Tasks)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>@task.Title</span>
@if (task.IsDone)
{
<span class="badge bg-success">Done</span>
}
</li>
}
</ul>
}
</div>
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
```
### 3.4 Test the Basic Functionality
1. **Stop** any running instance of the application
2. **Run** the application: `dotnet run`
3. **Navigate** to `/Tasks` in your browser
4. **Test** the following:
- The page loads with "No tasks yet." message
- Enter a task title and click "Add Task"
- The page reloads with your task in the list
- The "Task added." success message appears
- Try submitting an empty form—validation error should appear
At this point, you have a fully functional (but traditional) server-rendered page. Every interaction causes a full page reload. In the next steps, we will refactor this into the "Fragment First" architecture.
---
## Step 4: Identify Fragment Boundaries (5 minutes)
Before creating partials, we need to clearly identify which parts of the UI should become independent fragments.
### The Fragment Boundary Rule
> Any part of the UI that might update independently becomes a fragment boundary.
When htmx is added later, it will request and replace *only that fragment*—not the entire page.
### Fragment Boundaries for Our Tasks Page
Looking at our page, we identify **three** fragment boundaries:
| Fragment | Element ID | Purpose | When It Updates |
|----------|------------|---------|-----------------|
| **Messages** | `#messages` | Success/error notifications | After create, delete, or error |
| **Form** | `#task-form` | Create task form | After validation errors, form reset |
| **List** | `#task-list` | Display all tasks | After create, delete, refresh |
### Visual Representation
```
┌─────────────────────────────────────────────────────────────┐
│ Tasks Page │
├─────────────────────────────────────────────────────────────┤
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ #messages - Success/error messages │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────┐ ┌─────────────────────────────────┐ │
│ │ #task-form │ │ #task-list │ │
│ │ │ │ │ │
│ │ - Title input │ │ - Task 1 │ │
│ │ - Submit button │ │ - Task 2 │ │
│ │ │ │ - Task 3 │ │
│ └─────────────────────┘ └─────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
### Important Design Consideration
Fragment boundaries are about **product UX**, not technical layering. Ask yourself:
- "What does the user expect to change when they perform this action?"
- "What should stay the same?"
For example, when a user creates a task:
- The **list** should update (show the new task)
- The **messages** should update (show "Task added")
- The **form** might reset (clear the input)
Each of these is a separate concern, so each becomes a fragment.
---
## Step 5: Create the Partial Views (10–12 minutes)
Now we extract each fragment region into its own partial view. This is the core of the "Fragment First" pattern.
### 5.1 Create the Partials Folder
Create a new folder structure:
```
Pages/
└── Tasks/
├── Partials/
│ ├── _Messages.cshtml
│ ├── _TaskForm.cshtml
│ └── _TaskList.cshtml
├── Index.cshtml
└── Index.cshtml.cs
#
5.2 Create the Messages Partial
File: Pages/Tasks/Partials/_Messages.cshtml
@model string?
@*
Messages Fragment
=================
Purpose:
- Displays success/error messages to the user
- Wraps content in a stable #messages container
Design notes:
- The outer <div id="messages"> MUST always render, even when empty
- This ensures htmx always has a target to swap into
- In later labs, htmx will swap this entire element using hx-swap="outerHTML"
Model:
- string? message - The message to display, or null for empty state
*@
<div id="messages">
@if (!string.IsNullOrWhiteSpace(Model))
{
<div class="alert alert-success" role="alert">
@Model
</div>
}
</div>
Critical design point: The outer <div id="messages"> must always render, even when there is no message. This ensures htmx always has a valid target element to replace.
#
5.3 Create the Task Form Partial
File: Pages/Tasks/Partials/_TaskForm.cshtml
@model RazorHtmxWorkshop.Pages.Tasks.IndexModel
@*
Task Form Fragment
==================
Purpose:
- Renders the "Create Task" form
- Handles validation error display
- Wraps content in a stable #task-form container
Design notes:
- We pass the entire IndexModel as the model because we need:
- Input.Title for the field value
- ModelState for validation messages
- The form uses asp-page-handler="Create" which generates ?handler=Create
- We are NOT adding htmx attributes yet—that comes in Lab 2
Fragment contract:
- Root element: <div id="task-form">
- This ID is the "contract" for htmx targeting
*@
<div id="task-form">
<form method="post" asp-page-handler="Create">
<div class="mb-3">
<label class="form-label" for="title">Task Title</label>
<input id="title"
class="form-control"
asp-for="Input.Title"
placeholder="e.g., Add htmx to Razor Pages" />
<span class="text-danger" asp-validation-for="Input.Title"></span>
</div>
<button class="btn btn-primary" type="submit">Add Task</button>
</form>
</div>
Why pass the full IndexModel:
The form partial needs access to:
Model.Input.Title- to populate the input field (especially after validation errors)ModelState- which is accessed via theasp-validation-fortag helper
Passing the full model keeps the partial self-contained.
#
5.4 Create the Task List Partial
File: Pages/Tasks/Partials/_TaskList.cshtml
@using RazorHtmxWorkshop.Models
@model IReadOnlyList<TaskItem>
@*
Task List Fragment
==================
Purpose:
- Displays all tasks in a list format
- Shows empty state when no tasks exist
- Wraps content in a stable #task-list container
Design notes:
- Model is just the list of tasks, not the full PageModel
- This keeps the partial focused on display logic only
- The list-group classes are Bootstrap styling
Fragment contract:
- Root element: <div id="task-list">
- This ID is the "contract" for htmx targeting
*@
<div id="task-list">
@if (Model.Count == 0)
{
<p class="text-muted">No tasks yet.</p>
}
else
{
<ul class="list-group">
@foreach (var task in Model)
{
<li class="list-group-item d-flex justify-content-between align-items-center">
<span>@task.Title</span>
@if (task.IsDone)
{
<span class="badge bg-success">Done</span>
}
</li>
}
</ul>
}
</div>
Design decision - Model type:
This partial receives IReadOnlyList<TaskItem> rather than the full IndexModel because:
- It only needs the task data to render
- It doesn't need form state or flash messages
- Simpler models are easier to test and reason about
#
Step 6: Compose the Page with Partials (8–10 minutes)
Now we update the main page to use our new partials instead of inline markup.
#
6.1 Update the Index Page
File: Pages/Tasks/Index.cshtml
@page
@model RazorHtmxWorkshop.Pages.Tasks.IndexModel
@{
ViewData["Title"] = "Tasks";
}
@*
Tasks Page - Fragment Composition
==================================
This page demonstrates the "Fragment First" architecture:
- Each region is rendered by a dedicated partial
- Each partial has a stable root element ID
- The page is a composition of independent fragments
Fragment inventory:
- #messages -> Partials/_Messages (flash message display)
- #task-form -> Partials/_TaskForm (create task form)
- #task-list -> Partials/_TaskList (task list display)
In later labs, htmx will target these IDs to swap individual fragments
without reloading the entire page.
*@
<h1>Tasks</h1>
@*
Messages Fragment
-----------------
Displays success/error messages.
Model: FlashMessage (string?)
*@
<partial name="Partials/_Messages" model="Model.FlashMessage" />
<div class="row">
@*
Form Fragment
-------------
Create task form with validation.
Model: Full IndexModel (needs Input + ModelState)
*@
<div class="col-md-5">
<h2 class="h5">Add a Task</h2>
<partial name="Partials/_TaskForm" model="Model" />
</div>
@*
List Fragment
-------------
Displays all tasks.
Model: Tasks collection (IReadOnlyList<TaskItem>)
*@
<div class="col-md-7">
<h2 class="h5">Current Tasks</h2>
<partial name="Partials/_TaskList" model="Model.Tasks" />
</div>
</div>
@section Scripts {
<partial name="_ValidationScriptsPartial" />
}
#
6.2 Understanding the Partial Tag Helper
The <partial> tag helper is the recommended way to render partials in ASP.NET Core:
<partial name="Partials/_Messages" model="Model.FlashMessage" />
Path resolution:
"Partials/_Messages"looks forPages/Tasks/Partials/_Messages.cshtml- The path is relative to the current page's folder (
Pages/Tasks/)
#
6.3 Test the Refactored Application
- Stop and restart the application
- Navigate to
/Tasks - Verify the functionality:
- Page displays correctly (same as before)
- Creating tasks works
- Validation errors display
- Success messages appear
- View the HTML source to confirm the fragment IDs are present
#
Step 7: Verification Checklist (2 minutes)
Before moving on to Lab 2, verify your application meets all requirements.
#
DOM Verification
Open your browser's Developer Tools (F12) and confirm these elements exist:
<!-- Messages fragment -->
<div id="messages">...</div>
<!-- Form fragment -->
<div id="task-form">...</div>
<!-- List fragment -->
<div id="task-list">...</div>
```
### Functionality Verification
| Test | Expected Result |
|------|-----------------|
| Navigate to `/Tasks` | Page loads with empty task list |
| Submit empty form | Validation error: "Title is required." |
| Enter title, submit | Page reloads, task appears, "Task added." shows |
| Refresh page | Task persists, success message gone (TempData consumed) |
### File Structure Verification
Confirm your project structure matches:
```
RazorHtmxWorkshop/
├── Data/
│ └── InMemoryTaskStore.cs
├── Models/
│ └── TaskItem.cs
├── Pages/
│ ├── Tasks/
│ │ ├── Partials/
│ │ │ ├── _Messages.cshtml
│ │ │ ├── _TaskForm.cshtml
│ │ │ └── _TaskList.cshtml
│ │ ├── Index.cshtml
│ │ └── Index.cshtml.cs
│ └── ... (other pages)
└── ... (other files)
#
Key Takeaways
#
The "Fragment First" Mental Model
- Partials are not optimization—they are interfaces for future interactivity
- The fragment root element (with
id="...") is the contract—htmx will target these IDs - Each fragment has a single responsibility—messages, form, or list
#
What Comes Next
In Lab 2, you will add htmx to this foundation:
- The create form will submit via
hx-postand receive an updated_TaskListfragment - Only
#task-listwill update—no full page reload - You will add a "Refresh list" button using
hx-get - Validation errors will swap into
#task-formusing response headers
The "Fragment First" architecture you built in this lab makes all of that possible with minimal changes to your existing code.
#
Troubleshooting
#
Common Issues and Solutions
#
Debug Tips
- View Page Source: Check that all fragment IDs render correctly
- Network Tab: Verify form posts to
?handler=Create - Breakpoints: Set breakpoints in
OnGet()andOnPostCreate()to trace execution
#
Summary
You have successfully completed Lab 1! You now have:
- A Razor Pages application with a clean project structure
- A simple domain model with in-memory storage
- Three partial views representing independent UI fragments
- Stable, predictable element IDs for future htmx targeting
This "Fragment First" architecture is your foundation for building highly interactive server-driven UIs. In Lab 2, you will see how htmx leverages these fragments to create a responsive experience without the complexity of a JavaScript framework.
Proceed to Lab 2: Partial Updates with hx-get + hx-post →