This continues from Standardizing CotomyPageController for Shared Screen Flows .
In daily CRUD-heavy business application development, screens tend to drift when each one is built as a separate local solution. Search conditions, edit flows, read-only displays, and API loading rules gradually diverge, and after that even small changes start requiring more reading than they should.
In CRUD-heavy business applications, I usually treat screens as three groups. They are search screens, detail and edit screens, and screens that do not really belong to either of those categories.
For ordinary CRUD work, the first two are usually the main path. Search screens list and narrow down records. Detail and edit screens load one record, show its current state, and post changes back.
There are also read-only screens that only display data. When the target is not input elements, I usually use the renderer. And if a screen is only for display, I do not always use a form at all. Sometimes I call CotomyApi directly and apply the response to the page.
The code below shows these patterns in the simplest possible shape. The entity uses only Id and Name. The search sample keeps only a plain search form. The edit sample uses only input elements. The read-only sample renders values into table cells. This article stays at that category level on purpose. Combining those categories into one CRUD workflow is a separate step, and I treat that as the subject of the next article.
One Pattern, Three Shapes
The practical structure is usually the same, even though the screens look different.
| Screen type | Input path | Load path | Render path | Main boundary |
|---|---|---|---|---|
| Search | Query string | Server request | Server render | URL-driven screen |
| Edit | Form submit through API | API GET | Input fill | Screen + API contract |
| Read-only | No form | API GET | Renderer | Display boundary |
That is why these screens are easy to standardize later. The category changes, but the screen shape usually does not.
Most business screens are not unique interaction problems. They are repeated operational patterns.
The reason I keep these categories separate is that each one has a different data flow and a different ownership boundary. Search screens are request-driven, edit screens are entity-driven, and read-only screens are display-driven. If those responsibilities are mixed too freely, the screen becomes harder to understand and state consistency becomes harder to preserve over time.
So the practical rule is simple. Each screen category should keep one fixed data flow and one clear ownership boundary. Search screens should remain URL-driven, edit screens should remain API-driven, and read-only screens should remain display-driven unless there is a concrete reason to do otherwise. Mixing those patterns too casually increases complexity and makes state inconsistency more likely.
Cotomy matters here because the screen can stay in one DOM-first model even when the interaction style changes. Query forms, API forms, renderer-based display, and page-level orchestration all stay within the same screen boundary instead of forcing different UI models for different operations. That is the main reason this article is not only a general Razor Pages style note. Form, API, renderer, and page controller behavior are designed to stay inside one consistent screen model. This categorization also exists to avoid the kind of state fragmentation described in the Problem series.
Search Screens
For search screens, I usually use the query string and let the server perform the filtering and list rendering. That keeps the URL state aligned with the screen state, and it makes reload, bookmark, and back-navigation behavior easy to understand. I keep this pattern on the server side because search results are naturally request-driven. Users expect the URL to represent the current filter state, and server rendering keeps that relationship explicit. In real systems, this screen category usually grows beyond a single keyword field. Paging, sort order, and other search conditions are often added to the same request boundary. That is another reason I keep these screens on query strings. When users change conditions or move between pages, I usually want that state to remain in browser history as-is so back navigation and forward navigation continue to work naturally.
Search Screen Razor Page
This Razor Page keeps the search form minimal and renders the list on the server side. The first cell holds the entity key, and the row transition is triggered from that cell on the client side.
@page "/sample-entities"
@model SampleEntitySearchModel
<h1>Sample Entities</h1>
<form id="sample-entity-search-form" method="get">
<label for="keyword">@nameof(SampleEntitySearchQuery.Name)</label>
<input id="keyword"
name="@nameof(SampleEntitySearchQuery.Name)"
value="@Model.Query.Name" />
<button type="submit">Search</button>
</form>
<table>
<thead>
<tr>
<th>@nameof(SampleEntityListItem.Id)</th>
<th>@nameof(SampleEntityListItem.Name)</th>
</tr>
</thead>
<tbody>
@foreach (var entity in Model.Entities)
{
<tr>
<td data-entity-id="@entity.Id">@entity.Id</td>
<td>@entity.Name</td>
</tr>
}
</tbody>
</table>
Search Screen Page Model
The PageModel only reads the query string and asks the repository for the filtered list. The persistence side is intentionally abstracted away because the important part here is the screen pattern.
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
public class SampleEntitySearchModel : PageModel
{
private readonly ISampleEntityRepository _repository;
public SampleEntitySearchModel(ISampleEntityRepository repository)
{
_repository = repository;
}
public SampleEntitySearchQuery Query { get; private set; } = new();
public IReadOnlyList<SampleEntityListItem> Entities { get; private set; } = [];
public async Task OnGetAsync([FromQuery] SampleEntitySearchQuery query)
{
Query = query;
Entities = await _repository.SearchAsync(query.Name);
}
}
public class SampleEntitySearchQuery
{
public string Name { get; set; } = string.Empty;
}
public class SampleEntityListItem
{
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
}
public interface ISampleEntityRepository
{
Task<IReadOnlyList<SampleEntityListItem>> SearchAsync(string name);
Task<SampleEntity?> GetByIdAsync(string id);
Task<SampleEntity> InsertAsync(SampleEntity entity);
Task<SampleEntity> UpdateAsync(SampleEntity entity);
}
public class SampleEntity
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
}
Search Screen TypeScript
The TypeScript side initializes CotomyQueryForm and adds one click handler for the first table cell. Clicking that cell moves to the edit screen for the selected entity.
import { CotomyElement, CotomyPageController, CotomyQueryForm } from "cotomy";
CotomyPageController.set(class extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
this.setForm(
CotomyElement.byId<CotomyQueryForm>(
"sample-entity-search-form",
class extends CotomyQueryForm {}
)!
);
this.body.onSubTree(
"click",
"td[data-entity-id]",
(event: Event) => {
const cell = (event.target as HTMLElement).closest("td[data-entity-id]");
const id = cell?.getAttribute("data-entity-id")?.trim();
if (!id) {
return;
}
location.href = `/sample-entities/edit/${encodeURIComponent(id)}`;
}
);
}
});
In practice, this pattern works well when the screen should remain URL-driven. The state stays visible in the address bar, paging and condition changes stay in browser history, and the server remains responsible for the list output.
Detail And Edit Screens
For detail and edit screens, I usually load and save through API calls. The page itself owns the screen boundary, but the data flow is handled through the API form. That keeps create and update inside one screen pattern without pushing the whole interaction back into server postback flow. The reason is practical. Once a user is editing a record, I do not want the whole page lifecycle to depend on full postback refresh. API-driven load and save keep the screen responsive while still preserving one clear endpoint contract.
Edit Screen Razor Page
This page keeps the edit form to plain input elements only. The screen decides whether it is new or existing from the route value and passes that key to Cotomy through the form attribute.
@page "/sample-entities/edit/{id?}"
@model SampleEntityEditModel
@{
var entityKey = string.IsNullOrWhiteSpace(Model.EntityKey)
? (RouteData.Values["id"]?.ToString() ?? string.Empty)
: Model.EntityKey;
var isNew = string.IsNullOrWhiteSpace(entityKey);
}
<h1>@(isNew ? "New Sample Entity" : "Sample Entity Detail")</h1>
<form id="sample-entity-edit-form"
action="/api/sample-entities"
data-cotomy-entity-key="@entityKey">
<div>
<a href="/sample-entities">Back to List</a>
<button type="submit">Save</button>
</div>
<div id="edit-status"></div>
<table>
<tbody>
<tr>
<th>@nameof(SampleEntitySaveRequest.Id)</th>
<td>
<input name="@nameof(SampleEntitySaveRequest.Id)"
value="@entityKey"
readonly />
</td>
</tr>
<tr>
<th>@nameof(SampleEntitySaveRequest.Name)</th>
<td>
<input name="@nameof(SampleEntitySaveRequest.Name)" />
</td>
</tr>
</tbody>
</table>
</form>
Edit Screen Page Model
The PageModel only exposes the route key to the Razor Page. Load and save are handled through the API form, so the page model stays small.
using Microsoft.AspNetCore.Mvc.RazorPages;
public class SampleEntityEditModel : PageModel
{
public string EntityKey { get; private set; } = string.Empty;
public void OnGet(string? id)
{
EntityKey = id?.Trim() ?? string.Empty;
}
}
Edit Screen API Controller
The controller keeps the contract simple. GET returns one entity for load, POST creates a new record, and PUT updates an existing one.
using Microsoft.AspNetCore.Mvc;
using System.Text.Json.Serialization;
[ApiController]
[Route("api/sample-entities")]
public class SampleEntitiesApiController : ControllerBase
{
private readonly ISampleEntityRepository _repository;
public SampleEntitiesApiController(ISampleEntityRepository repository)
{
_repository = repository;
}
[HttpGet("{id}")]
public async Task<ActionResult<SampleEntityResponse>> Get(string id)
{
var entity = await _repository.GetByIdAsync(id);
if (entity is null)
{
return NotFound();
}
return Ok(new SampleEntityResponse
{
Id = entity.Id,
Name = entity.Name
});
}
[HttpPost]
public async Task<ActionResult<SampleEntityResponse>> Post([FromForm] SampleEntitySaveRequest request)
{
var entity = new SampleEntity
{
Id = request.Id.Trim(),
Name = request.Name?.Trim() ?? string.Empty
};
var saved = await _repository.InsertAsync(entity);
return CreatedAtAction(nameof(Get), new { id = saved.Id }, new SampleEntityResponse
{
Id = saved.Id,
Name = saved.Name
});
}
[HttpPut("{id}")]
public async Task<ActionResult<SampleEntityResponse>> Put(string id, [FromForm] SampleEntitySaveRequest request)
{
var entity = await _repository.GetByIdAsync(id);
if (entity is null)
{
return NotFound();
}
entity.Name = request.Name?.Trim() ?? string.Empty;
var saved = await _repository.UpdateAsync(entity);
return Ok(new SampleEntityResponse
{
Id = saved.Id,
Name = saved.Name
});
}
}
public class SampleEntitySaveRequest
{
[JsonPropertyName(nameof(Id))]
public string Id { get; set; } = string.Empty;
[JsonPropertyName(nameof(Name))]
public string Name { get; set; } = string.Empty;
}
public class SampleEntityResponse
{
[JsonPropertyName(nameof(Id))]
public string Id { get; set; } = string.Empty;
[JsonPropertyName(nameof(Name))]
public string Name { get; set; } = string.Empty;
}
Edit Screen TypeScript
The TypeScript side is where the GUI behavior is wired. CotomyEntityFillApiForm handles the load and submit cycle, while the page controller only connects the screen and shows simple status text.
import {
CotomyApiResponse,
CotomyElement,
CotomyEntityFillApiForm,
CotomyPageController
} from "cotomy";
CotomyPageController.set(class extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
const status = this.body.first("#edit-status");
if (!status) {
return;
}
this.setForm(CotomyElement.byId("sample-entity-edit-form", class extends CotomyEntityFillApiForm {
public override initialize(): this {
if (this.initialized) {
return this;
}
super.initialize();
this.apiFailed((event) => {
status.text = `API failed. status: ${event.response.status}`;
});
this.submitFailed((event) => {
status.text = `Submit failed. status: ${event.response.status}`;
});
return this;
}
protected override async fillAsync(response: CotomyApiResponse): Promise<void> {
await super.fillAsync(response);
if (!response.ok || !response.available) {
return;
}
const entity = await response.objectAsync<{ Id: string; Name: string }>({
Id: "",
Name: ""
});
status.text = `Loaded ${entity.Id} ${entity.Name}`.trim();
}
protected override async submitToApiAsync(formData: FormData): Promise<CotomyApiResponse> {
const response = await super.submitToApiAsync(formData);
if (!response.ok || !response.available) {
return response;
}
const entity = await response.objectAsync<{ Id: string; Name: string }>({
Id: "",
Name: ""
});
status.text = response.status === 201
? `Created ${entity.Id}`
: `Saved ${entity.Id}`;
return response;
}
})!);
}
});
In practice, this pattern works well when one screen needs to load, edit, and save the same entity without falling back to full postback refresh. The screen stays predictable because load and save move through one API contract while the page controller still owns the screen entry.
Read-Only Screens
For read-only screens, server rendering is usually the more natural default. If the screen only needs to show one entity and the display can be completed in one server response, rendering it on the server is often the simpler choice. That is especially true for publicly visible pages, where crawlability and predictable first render matter more.
Even so, I do not think read-only screens must always stay fully server-rendered. There are cases where I still consider the renderer pattern shown here. For example, I may want the screen to follow the same client-side structure as edit screens, or I may need to assemble the display from multiple entities, smart-enum style values, or other API-driven data that is easier to combine after the page is already loaded.
In those cases, I usually avoid wrapping the screen in a form and call CotomyApi directly. Then I apply the response through the renderer. That keeps the screen in a display-only category while still allowing the UI to share patterns with other screens.
Read-Only Razor Page
This screen does not use a form. It only defines the display area and the bind targets that will receive the API response.
@page "/sample-entities/view/{id?}"
@model SampleEntityViewModel
<h1>Sample Entity View</h1>
<div>
<label for="view-id">@nameof(SampleEntityResponse.Id)</label>
<input id="view-id" value="@Model.EntityKey" />
<button type="button" id="load-button">Load</button>
</div>
<table id="sample-entity-view">
<tbody>
<tr>
<th>@nameof(SampleEntityResponse.Id)</th>
<td data-cotomy-bind="@nameof(SampleEntityResponse.Id)"></td>
</tr>
<tr>
<th>@nameof(SampleEntityResponse.Name)</th>
<td data-cotomy-bind="@nameof(SampleEntityResponse.Name)"></td>
</tr>
</tbody>
</table>
Read-Only Page Model
The read-only page model is only responsible for the initial route value. The actual data load is done from TypeScript.
using Microsoft.AspNetCore.Mvc.RazorPages;
public class SampleEntityViewModel : PageModel
{
public string EntityKey { get; private set; } = string.Empty;
public void OnGet(string? id)
{
EntityKey = id?.Trim() ?? string.Empty;
}
}
Read-Only TypeScript
This is the simplest display-only pattern. The page calls CotomyApi directly and applies the response to the table through CotomyViewRenderer.
import {
CotomyApi,
CotomyElement,
CotomyPageController,
CotomyViewRenderer
} from "cotomy";
CotomyPageController.set(class extends CotomyPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
const loadButton = this.body.first("#load-button");
const input = this.body.first("#view-id");
const view = this.body.first("#sample-entity-view");
if (!loadButton || !input || !view) {
return;
}
const renderer = new CotomyViewRenderer(view);
loadButton.on("click", async (event: Event) => {
event.preventDefault();
const id = input.value.trim();
if (!id) {
return;
}
const response = await new CotomyApi().getAsync(`/api/sample-entities/${encodeURIComponent(id)}`);
await renderer.applyAsync(response);
});
}
});
This kind of structure is usually worth considering when read-only display is not really simple output anymore. If one screen needs multiple API values, shared client-side formatting rules, or the same screen composition rules used elsewhere in the application, renderer-based display can still be the more practical option even without edit behavior.
In practice, this pattern works well when display is still screen logic even though there is no submit path. It avoids forcing a form model where none is needed, while still letting the screen stay close to the same client-side conventions as the rest of the application.
The Main Pattern
In my own systems, I often turn these categories into base classes. Even then, the core point is not the inheritance itself. The important part is that most screens can be implemented with the same pattern once the category is clear.
That consistency matters in large business applications. When the same screen category always follows the same structure, people do not need to read deeply just to understand how the screen works. They can focus on the domain-specific part instead.
In practice, that is one of the main reasons I keep returning to these patterns. Most business screens are not unique interaction problems. They are repeated operational patterns, and repeating the same implementation shape makes those systems easier to build, easier to understand, and easier to maintain.
More directly, this is the direction in which I implemented Cotomy itself. I wanted to build the CRUD-centered business applications I work on every day more quickly, while still allowing a reasonably rich interface where screens can search, edit, and display API results without collapsing back into scattered DOM code. The way I approached that was to think through the recurring CRUD patterns first, then implement class structures that fit those patterns. That is why Cotomy has this shape. It is not a general-purpose abstraction invented first and applied later. It is a structure that came from repeatedly building the same kinds of business screens and trying to make those screens faster to implement, easier to understand, and easier to keep consistent.
This is also the same direction I use in the project templates. The examples in this article are intentionally minimal so the screen pattern stays visible. The templates follow the same screen categories, but they organize them further with shared classes and common infrastructure so they can work as a practical application foundation rather than only as isolated examples.
In short, the rule is not to make every screen look the same. It is to keep each category internally consistent so that screens remain predictable, onboarding cost stays lower, and the same business application can keep growing without every screen inventing its own interaction model. That predictability reduces cognitive load for both the original author and the next person reading the screen.
Use the search pattern when the screen mainly filters and lists records with URL state. Use the edit pattern when one entity must be loaded and mutated through an API-driven form. Use the read-only pattern when the screen mainly displays values and has no real input responsibility.
Practical Guide
This article is part of the Cotomy Practical Guide, which focuses on hands-on usage patterns for the framework.
Series articles: Working with CotomyElement , CotomyPageController in Practice , Standardizing CotomyPageController for Shared Screen Flows , and Building Business Screens with Cotomy.
Next
Next article: Handling Validation and Error Display with Cotomy, focusing on how to keep field validation, submit errors, and API failures inside one consistent screen flow.
Links
Previous: Standardizing CotomyPageController for Shared Screen Flows . More posts: /posts/ .