Previous article: Integrating TypeScript into Razor Pages with Cotomy
In the previous note, I summarized the structure I currently use when developing with Razor Pages. There I explained that I place page-level TypeScript entry points at each endpoint. This time, I want to continue from that point and write about the shared foundation that sits behind those entry points.
Once page-level TypeScript starts increasing, some common base becomes unavoidable. A page controller base class, form helpers, rendering helpers, and shared UI parts all need one place to live. The practical question is not whether a shared foundation exists. The question is where that foundation should be placed and what boundary it should belong to.
My first idea was wrong
When I first started building this style of system, I placed the TypeScript foundation in a separate folder directly under the solution root. Each page entry point imported shared modules from there.
The structure looked roughly like this.
Solution
ts
controller.ts
form.ts
renderer.ts
MainProject
Pages
Sales
Orders
Index.cshtml
Index.cshtml.ts
That approach did provide code sharing. In that narrow sense, it worked.
But the problems were obvious once the system grew.
First, page entry points became heavily affected by the internal folder structure of that shared TypeScript area. If the foundation was reorganized, imports across the application were dragged along with it. That is not what I want from a module that claims to be independent.
Second, even if the classes inside that folder were designed with some abstraction, the module itself was not truly independent. The application was still reaching directly into the internal file arrangement of the shared area. That creates friction for refactoring and makes the so-called foundation much less portable than it appears at first.
Third, if a foundation is really meant to be a foundation, it is natural to want to use it in multiple systems. But a shared folder sitting under one solution and being imported through local path assumptions is not a stable form for reuse. It is only a local convenience.
At that point I had to admit that the structure was not merely imperfect. It reflected a mistaken boundary.
The real mistake was how I drew the line
This is related to the boundary problem I have mentioned several times in this series.
If a project is large, if staffing is abundant, and if server-side work and frontend work are handled by different specialists, then drawing a boundary between server-side and frontend development can be reasonable. In that situation, separate ownership and separate project structure often have organizational value.
But that is not the reality of most of my projects.
In practice, it is usually just me, or at most a couple of people joining partially. In that environment, I cannot develop the server side and frontend as if they were independent delivery worlds. I have to build through the server and through the frontend as one continuous path.
When I inserted an unnecessary line between those areas, the result was a distorted structure. One project ended up depending on multiple foundations living in physically separate places. The server-side base lived in one place. The frontend base lived elsewhere. Yet the actual work still crossed both every time one screen was implemented.
That was the real problem.
From a pure dependency point of view, the frontend foundation built on Cotomy does not need Razor Pages specifically. The server could be written in PHP or Ruby and the Cotomy side could still work. On the other side, the server is ultimately deciding what HTML to emit. In that abstract sense, there is no forced implementation dependency between them.
But when I think specifically about building a web application with Razor Pages and Cotomy, I do not think those sides should be treated as two unrelated foundations by default. I think they should be treated as one application foundation with different responsibilities inside it.
So I moved the TypeScript foundation into Core
That is why I eventually decided to place the TypeScript foundation inside Core.
After all, in Razor Pages projects and RCL projects, I am already writing TypeScript that belongs to those projects. Putting the shared frontend base outside the project structure at the solution root was strange from the beginning.
To make this clearer, Core in this structure is not a dump for anything shared. I treat it as the shared application foundation across server-side and frontend concerns. It may contain common page controller bases, shared form helpers, shared rendering helpers, and other infrastructure that supports multiple screens. But it should not contain business logic, because business rules belong to the application and domain side. And it should not contain page-specific behavior, because page-specific flow belongs to the screen entry that owns that endpoint. Core is the side many other parts depend on, so it is important to keep it thin and stable instead of letting responsibilities accumulate there.
The structure I now prefer looks more like this.
Solution
Core
Core.csproj
_front
src
index.ts
controller.ts
form.ts
renderer.ts
elements.ts
UISample
UISample.csproj
Pages
UISample
FormBase.cshtml
FormBase.cshtml.ts
FormEntity.cshtml
FormEntity.cshtml.ts
I created a folder named _front under Core and placed the shared TypeScript there.
The underscore is a small detail, but it is intentional. I did not want the TypeScript area to visually blend into the C# folders. The shared foundation is part of the same project boundary, but it still helps me to distinguish the frontend source area immediately when I scan the tree.
This arrangement made the overall structure much easier for me to understand. Core became the place where the common foundation lives across both C# and TypeScript. Individual page entry points still stay beside each Razor Page, but the reusable base they depend on belongs to Core rather than to a detached solution-level directory.
The structural failure of the old solution-root approach can be stated more directly. It created path dependence, because page entries were forced to know where the shared frontend files physically lived. It leaked internal structure, because application code imported through file layout instead of a public module surface. And it widened refactoring impact, because reorganizing the shared area immediately changed import paths across the screen layer. For example, moving one shared controller file or reorganizing the shared folder forced import-path changes across many page entries even when the public behavior had not changed at all. That is why I no longer think of it as a minor inconvenience. It is a boundary mistake.
The next problem was how pages should import Core
Once I moved the shared frontend foundation into Core, I had to decide how page entry points should reference it.
If a page entry point directly imports internal files under Core/_front/src, the same structural problem returns immediately. The entry point becomes coupled to the internal file layout of the shared foundation.
So I did not want imports like this to become the public style.
import { AppPageController } from "../../../Core/_front/src/controller";
Instead, I wanted page code to import Core as Core.
In the structure I use now, that is exactly what happens. The public entry for the frontend side of Core is Core/_front/src/index.ts, and the page scripts import from @core.
import { AppPageController } from "@core";
import { CotomyPageController } from "cotomy";
CotomyPageController.set(class extends AppPageController {
protected override async initializeAsync(): Promise<void> {
await super.initializeAsync();
}
});
This is the same shape used by libraries distributed through npm. External code imports from the package surface, not from arbitrary private files inside the package.
The important point is not the alias syntax itself. The important point is that the public surface is explicit.
In this structure, Core/_front/src/index.ts re-exports the types that pages are supposed to consume.
export { AppPageController, ListPageController } from "./controller";
export { DialogSurface, ProcessingArea, SidePanel } from "./elements";
export { CheckInputElement, EntityDetailForm, InputElement, SelectElement, TextAreaElement } from "./form";
export { AppViewRenderer, LookupEvent } from "./renderer";
That means page-level scripts depend on the Core surface, not on the internal arrangement behind that surface.
How the @core import is configured
The point is not just nicer import syntax. I also want TypeScript resolution and bundler resolution to follow the same public surface. If those rules drift apart, the source code may look stable while the actual build becomes fragile. Keeping both layers aligned means @core remains a real module boundary rather than just a TypeScript convenience.
In my setup, tsconfig.pages.json maps @core to Core/_front/src/index.ts.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@core": ["Core/_front/src/index.ts"]
}
}
}
And in webpack.pages.config.cjs, the alias is resolved so page entry points under each Pages folder can import the same module consistently during bundling.
resolve: {
extensions: [".ts", ".js"],
alias: {
cotomy: cotomyCjs,
"@core$": coreIndex,
"@core": coreRoot
}
}
This part matters because it keeps the import rule stable at both the TypeScript level and the bundler level. The page author writes import { AppPageController } from “@core”; and does not need to know where the internal file actually lives. Just as importantly, when the internal files inside Core move, I only need to update the public surface and the build mapping in one place instead of chasing import changes across many page entries.
That is exactly the level of indirection I wanted.
Page entries still stay at the endpoint boundary
None of this changes the rule from the previous article.
The page-specific TypeScript file still belongs beside the Razor Page file. In this arrangement, webpack scans each project and treats Pages/**/*.cshtml.ts as page entry points. Core is not replacing page-level entry points. Core is providing the shared base those entry points stand on.
That distinction is important.
The entry point belongs to the page because the page is the endpoint boundary. The shared controller classes, form helpers, and UI building blocks belong to Core because they are part of the common application foundation.
This arrangement works most naturally when the practical unit of change is the screen. In that situation, one implementation task usually reaches the Razor Page, the page model, the rendered HTML, and the page TypeScript together. If the practical unit of change is instead an independently released frontend layer, then a different structure can make more sense. Put more simply, if the change unit is the screen, integration is usually the more natural choice. If the change unit is the frontend as its own product, separation is usually the more natural choice.
Once I organized it this way, the structure became much more coherent. The project no longer depends on page scripts reaching into a detached TypeScript area. Instead, the common base is part of Core, and page entry points consume that base through a defined public surface.
Why I am writing about this mistake so openly
I am writing about this because it is a good example of a boundary error.
A class or a method needs semantic independence, not just technical separation. I think projects are similar. A project should also have a meaningful boundary. If the way I split projects does not match the way development actually flows, then the structure is only pretending to be clean.
In some systems, it may still make sense to treat server-side and frontend as separate units. I do not deny that. But when I think about ordinary web application development, especially in a small team or solo environment, I find it easier to understand the whole development cycle when the primary split is between business responsibilities and shared foundation rather than between server path and frontend path.
This point is not universal. I think it applies most naturally to small and mid-sized Razor Pages systems, especially when the same developer, or a tightly aligned team, is implementing one screen across both server-side and frontend work. In a larger organization with separate specialist teams, a different split may be more appropriate. I do not think this article describes a rule for every web project.
If I cut the system mainly by where the code runs, even though one screen usually changes across both sides at once, the structure becomes harder to follow. The separation does not really remove dependency in practice. Instead, it raises the cost of working across the boundary, because design, implementation, and investigation still have to cross it repeatedly. If I cut the system mainly by business role and foundation role, then the route from requirement to implementation tends to stay more visible.
There are also cases where the stronger split is the better design. If the frontend is independently released, owned by a separate team, or stabilized around a fixed API contract, then separating the frontend foundation more aggressively can be the correct choice. My point here is narrower than that. In the kind of Razor Pages system I am describing, the change unit is usually the screen rather than an independently evolving frontend product.
What I want here is not a server side and frontend that are tightly entangled or strongly dependent on each other. That is not the point. I simply want the structure to let me naturally relate those sides when I am designing a screen, investigating behavior, or tracing a problem across one piece of work. I want them to stay understandable as parts of one application without forcing them into one mixed responsibility.
That is why I now treat Core as one foundation that spans both C# and TypeScript, while still keeping the UI boundary itself clear. Cotomy remains on the UI side. Business logic still does not belong there. The goal is not stronger coupling. The goal is to avoid splitting the application into physically separate worlds in a way that makes normal development, investigation, and design thinking less natural than it needs to be.
I should also be clear about reuse. In this article, Core is an application foundation, not a general-purpose library intended for arbitrary projects. It is the shared base of one application structure. It may contain code that is reused widely inside that application, but that does not make it a cross-project library by itself. If true cross-project reuse becomes the main goal, then the reusable portion should be extracted and published as its own package instead of remaining only as part of Core.
Closing
At first, I thought placing shared TypeScript under the solution root was a reasonable way to centralize frontend code. It did centralize it, but it also exposed the wrong boundary and made page entry points depend too much on internal file layout.
Moving the shared frontend foundation into Core solved that problem more cleanly. It let Razor Pages and Cotomy form one coherent application foundation, while still preserving page-level entry points at each endpoint. And by exposing the shared frontend base through index.ts and the @core alias, page scripts now depend on Core as a module rather than on private file paths.
For me, that made the whole structure feel less distorted. The server side and frontend side are still different responsibilities, but they now live in a form that better matches how I actually build business web applications.
The next thing to explain is the data flow that sits on top of this structure. Once the shared foundation is in place, the remaining question is how persisted models, page models, and frontend state should stay aligned without turning into a naming mess.
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 , Shared Foundation Layout for Razor Pages and Cotomy , and Consistent Data Flow from Persisted Models to the Frontend .
Next article: Consistent Data Flow from Persisted Models to the Frontend , about keeping one naming path consistent from persisted models to Razor Pages forms and Cotomy bindings.