Integrating TypeScript into Razor Pages with Cotomy

A reflection on why I keep TypeScript beside Razor Pages screens, why I still define one page controller type per page, and how shared infrastructure makes that practical.

Previous article: How I Split Projects in Razor Pages Systems

In the previous note, I wrote about how I split projects in a Razor Pages system. This time, I want to move one layer closer to the screen and explain how I integrate TypeScript into that structure.

This blog is about Cotomy, and I am the author of Cotomy, so in practice most of the systems I build now use Cotomy or an earlier form of the same idea. The interesting question is not whether TypeScript exists in the system. The question is how to place it so the Razor Pages boundary remains understandable instead of getting blurred by frontend infrastructure.

To keep the scope clear from the beginning, I should separate three things. Cotomy itself provides a page-level UI boundary through CotomyPageController. Keeping page-local TypeScript beside Razor Pages files is not a Cotomy requirement, but an application architecture rule I use around it. At the same time, the basic idea of colocating page-specific files is not limited to Cotomy. I think that part generalizes reasonably well to Razor Pages development in general.

Cotomy is page-based, so I treat the page as a real unit

Cotomy is built around CotomyPageController as a page-level control boundary. In actual use, I call CotomyPageController.set with a subclass for the page entry and let that class gather page-local initialization and form coordination.

The important point for me is not only technical possibility, but meaning. A page controller should represent a page.

Technically, I can reuse the same controller class across multiple pages. I can also create a more specialized base class for list screens or edit screens and pass that type directly. In some real screens, the subclass I add does not even contain extra logic yet.

Even so, I still prefer to define one subclass per page. If the page exists as an endpoint, I want the page to exist as a type.

I know this can look unnecessary when the subclass is empty. But to me, the class still has a clear purpose. It exists to represent that page as an independent unit. If it currently contains no additional implementation, that only means the page does not need extra behavior yet. It does not mean the page has no identity of its own.

This is consistent with a point I keep repeating in this blog. A type is not only a container for code. It is also a way to state meaning and boundary. When I define a page-specific controller class, I am making that page explicit in the codebase, even before that page grows its own behavior later.

An empty subclass is also a practical extension point. It keeps the page searchable as a type, gives later changes one obvious place to land, and makes it easier for both humans and AI tools to identify where page-specific behavior is supposed to belong. In that sense, it works as a fixed point for change scope, not just as a symbolic type.

That is partly architecture and partly personal insistence. Cotomy requires a page controller boundary, but defining one named subclass per page is my own policy on top of that. I do not think reusing the same base class directly is a serious problem. There is little practical harm in doing so. But when I come back to a system later, I want the page boundary to remain visible in code, not only in routing.

The practical problem appears when pages start to branch

Once I follow one page, one page controller, the next problem is file placement.

A Razor Pages system normally grows with nested folders and endpoint structure like this.

Solution
  MainProject
    Pages
      Index.cshtml
      Sales
        Orders
          Index.cshtml
          Confirm.cshtml
        Shipments
          Index.cshtml
          Confirm.cshtml

If I create one page controller per screen, the natural place for the page TypeScript is beside the page file with the same name.

Solution
  MainProject
    Pages
      Index.cshtml
      Index.cshtml.ts
      Sales
        Orders
          Index.cshtml
          Index.cshtml.ts
          Confirm.cshtml
          Confirm.cshtml.ts
        Shipments
          Index.cshtml
          Index.cshtml.ts
          Confirm.cshtml
          Confirm.cshtml.ts

For me, this is the stable shape. The endpoint, the markup, and the page controller stay physically close to each other.

That proximity improves work in very ordinary ways. Access becomes simple. When I implement or modify a screen, I do not need to jump across multiple unrelated directories just to follow one unit of behavior. The relevant files are already nearby, so development becomes more direct and less error-prone. It also reduces the chance that a page-specific script is forgotten or left uncreated because the expected location is already part of the screen structure itself.

This also matters after implementation. During testing and after release, tickets usually begin from a screen. Someone reports that a specific page behaves incorrectly, displays the wrong data, or fails in one operation. When that happens, I want the investigation path to be obvious. If the page file and the page-level TypeScript are colocated, the route from reported screen to relevant implementation stays short and predictable. That has real value not only during development, but also in maintenance and incident response.

Even if Cotomy did not exist, I would still consider this kind of colocated layout a strong option in Razor Pages. Cotomy gives me a page controller model that fits the arrangement well, but the benefit of reducing search distance and keeping screen responsibility visible is broader than one framework.

Why I do not want a separate frontend tree

The alternative is obvious. I could create a separate frontend folder and gather all TypeScript there.

Solution
  MainProject
    Pages
      ...
    FrontEnd
      Index.ts
      Sales
        Orders
          Index.ts
          Confirm.ts
        Shipments
          Index.ts
          Confirm.ts

I understand why this structure exists. If frontend work and server-side work are assigned to different people or different teams, separating those trees can be organizationally reasonable. My first large web project in ASP.NET was close to that reality, and at that time it actually helped me. Most of my background was in C# and Windows application development, so JavaScript felt difficult to me in a very direct way. A loosely constrained language was simply harder for me to handle well then. To be fair, it is still not the kind of thing I would describe as relaxing even now.

That is why I do not think this is a universal right-or-wrong question. If everything is colocated around the page, small teams will naturally tend toward one person carrying one feature from server to frontend. For a solo developer or a small team with full-stack expectations, that is often a benefit. But if team size is larger, or if members have clearly different strengths, a more separated structure can be the better fit. The best arrangement changes with team scale and with the technical range the people involved can actually cover.

But when one person or a small team is building one screen end to end, this becomes an anti-pattern for me. The screen is one unit of work, yet the files that define that unit are pushed apart. Every modification becomes a small search task. The server-side endpoint is in one place, the rendered markup is in another, and the page control logic is somewhere else again. That may sound minor, but repeated hundreds of times, it becomes real friction.

The cost is not only editing speed. When a bug appears, tracking the responsibility chain becomes slower because one screen no longer has one obvious implementation area. The same separation also makes AI-assisted editing less reliable, because the model sees one fragment and more easily invents the rest of the structure incorrectly. Over time, the practical result is that the screen boundary becomes weaker even though the endpoint boundary still exists.

My rule is simple: keep the page script beside the page

So I keep the TypeScript file in the same location as the cshtml file and give it the same base name. Once I do that, one screen becomes easier to read as one object.

That also fits how I think about CotomyPageController. Even if the actual registration is short, the screen still has one visible control point.

import { CotomyPageController } from "cotomy";

export class OrderConfirmPageController extends CotomyPageController {
    protected override async initializeAsync(): Promise<void> {
        await super.initializeAsync();
    }
}

CotomyPageController.set(OrderConfirmPageController);

This example is intentionally small. The point is not that every page needs much code. The point is that the page has its own name and its own control boundary.

The shared foundation matters more than the page script itself

Of course, placing TypeScript beside each page only works if the infrastructure supports it. I do not want every screen to manually specify a script path or manually register a build entry one by one.

So in my own systems, I build a small shared foundation around that rule.

The server side can derive the page script path from the endpoint path. The frontend build can scan the page tree and treat colocated TypeScript files as page entries. The layout can load only the script that belongs to the current endpoint.

In concrete terms, the foundation usually does three jobs for me. It resolves a convention-based path from the current Razor Pages endpoint. It lets the build treat matching .cshtml.ts files as entry points without manual registration. And it keeps script injection at the layout level so each page only receives the frontend code that belongs to that page.

That is enough to make the rule tangible. In practice, this usually means the layout or a shared Razor helper resolves the current page script path from the endpoint and emits the corresponding script tag, while the frontend build discovers page-local .cshtml.ts entries by convention under the Pages tree. I do not need each author to remember webpack entries, script tags, or path mapping details for every screen. The shared foundation absorbs that coordination work once.

More importantly, this kind of rule becomes much stronger when the project foundation supports it from the beginning. I now prefer to make this structure part of the shared base itself, so page authors do not have to remember it manually every time.

Once that foundation exists, adding screen-specific TypeScript becomes almost mechanical. I place the file where the page already lives, and the rest follows the rule.

This is the same kind of idea I wrote about in the previous note when I discussed project boundaries. I do not want developers to remember structure by discipline alone. I want the structure to be easy to follow because the system itself keeps the rule in place.

The real value is continuity of thought

I have written this many times in other articles, but I think it matters enough to repeat. Human attention does not keep a large structure in active memory as well as we like to imagine. Some people are exceptionally good at relating distant files and distant concerns in their head, but I do not design around rare ability.

I design around ordinary limits, including my own.

The same problem now appears with AI-assisted development, but in a different form. A human struggles to keep too many distant parts in mind at once. An AI often does the opposite kind of failure. It reads the part of the system it has been shown, then fills the missing whole with a plausible guess. Depending on the model and the workflow, that guess can look convincing while still being structurally wrong. Colocation helps here as well, because the page-level context I need to hand to an AI becomes much simpler. In many cases, I can say that the relevant implementation is this Razor Page and the files beside it, instead of assembling a scattered set of references by hand.

If one screen is spread across too many locations, the mental cost of understanding that screen rises immediately. That cost does not show up as a dramatic architectural failure. It shows up as slower edits, more re-checking, more context loss, and more accidental omission. Keeping Razor Pages markup and page-local TypeScript together is one of the simplest ways I know to reduce that cost.

Where Cotomy ends and application infrastructure begins

This boundary is important.

Cotomy itself provides the page controller model and the page-level UI boundary. It does not dictate how a Razor Pages application should discover page scripts, how a bundler should scan the project, or how the server should emit script tags. Those are application-level integration decisions.

That separation is good. It lets Cotomy stay focused on page lifecycle and screen coordination while the host application decides how frontend assets should be organized and loaded.

In other words, the one page, one controller idea is part of Cotomy’s page model. The rule that same-name TypeScript files sit beside cshtml files is my application architecture decision built around that model.

Closing

When I integrate TypeScript into Razor Pages, I do not want a parallel frontend world. I want the TypeScript side to remain attached to the same endpoint boundary that already exists on the server side.

For that reason, I keep page-local TypeScript beside each Razor Page, define one page controller type per page even when the class is nearly empty, and build a small shared foundation so the loading rule stays automatic instead of manual.

That arrangement has improved my working speed noticeably because it preserves one screen as one visible unit.

More importantly, it makes the active scope of the current task easier for me to grasp, even though I do not think of myself as someone with especially exceptional cognitive capacity. Because the relevant range is easier to see before I start changing things, regressions caused by insufficient consideration have decreased compared with how I used to work. In practical terms, this has made it possible for me to build larger systems than before without the structure collapsing so easily under its own complexity.

At the same time, this alone is not enough to build a large system. In most real applications, various recurring behaviors end up being standardized into a shared foundation. The exact shape depends on what kind of system is being built and what architectural direction the project takes. How I organize that shared foundation while still keeping Razor Pages and Cotomy boundaries explicit is something I want to dig into in the next article.

In practice, I treat this as a small set of enforceable rules. Each Razor Page must have its own visible page controller type. Page-specific TypeScript must live beside the corresponding Razor Page and must follow the page name exactly. Pages must not manually register script tags or bundler entries one by one. Shared behaviors must belong in the foundation, not in repeated page-by-page improvisation.

C# Architecture Notes

This article is part of the Cotomy C# Architecture Notes, which reflect on backend and project-structure decisions around business systems.

Series articles: Why I Chose C# for Business Systems and Still Use It , From Global CSS Chaos to Scoped Isolation , Unifying Data Design and Code with Entity Framework , How I Split Projects in Razor Pages Systems , Integrating TypeScript into Razor Pages with Cotomy , and Shared Foundation Layout for Razor Pages and Cotomy .

Next article: Shared Foundation Layout for Razor Pages and Cotomy

Learn Cotomy

Cotomy is a DOM-first UI runtime for long-lived business applications.