Tech Series: Front-end patterns

Part 4 of the inveazy tech series. How hub screens stay maintainable: Razor layouts and partials, JavaScript that enhances the document against /api, and SQL that stays on the server.

Part 4 of the inveazy tech series. Part 3 was the host contract: people get HTML, hub scripts get JSON under /api, and a failed save must not replace the composer with an error page. This post is what that contract looks like on a hub screen — how Razor composes the document, how JavaScript enhances it, what “components” mean in this stack, and how the page gets rows without putting SQL in the browser.

Series:Web app and API architecture · Next → Multi-tenant design in practice


The idea in one line: The layout owns the shell. The script owns the grid. SQL stays behind the host.

A hub here is an operational screen inside a signed-in module — a list, a compose form, a dashboard, a till. You will see:

  • How Razor layouts and partials compose the document
  • Why JavaScript enhances without replacing
  • How SQL stays on the server, not in the browser
  • The patterns for first-paint data

The layout draws the chrome and gates. JavaScript enhances that Razor document without replacing it (progressive scripts: HTML first, then the script fills grids and drives save). Parameterized SQL runs on the server, in controllers and services, behind the /api boundary. The browser never owns the catalog or the connection string.

Why this matters before the frameworks

A multi-module B2B product invites a familiar fork: one SPA for every screen, or a pile of pages with copy-pasted fetch and no shared shape. The SPA path feels modern until every module wants its own client router and bundle. The copy-paste path feels simple until every list invents its own loading row, error toast, and permission check. inveazy chose documents with progressive scripts (HTML first, JavaScript enhances) on purpose.

Authorize → module layout → Razor shell → hub JS → /api JSON → update the DOM

Anatomy of a hub page

By “components” here, we mean Razor partials on the server and small JavaScript modules on the client — not a React or Vue tree that owns every route. Open CRM organizations or inventory products and you are looking at this anatomy.

The module layout layer

A typical hub page is not an empty root waiting for a client router. A module layout wraps a shared base layout and draws the chrome every screen in that module shares: top bar, side nav, page title, and the script sections the module needs. The area’s _ViewStart picks that layout so CRM pages get the CRM shell, inventory pages get the inventory shell, and so on. That is what you see when you jump from warehouse to CRM without leaving the same signed-in site.

Page body and scripts

The page body is the work surface — toolbar, card, empty or loading table, compose panes. Scripts sit in a page or layout section so shared helpers load once and feature code stays next to the screen that owns it. Navigation between modules is still a document load. Inside a page, users get the SPA feel — grids update from JSON, no full reload for every filter — without the SPA complexity: no client router, no second frontend owning auth. The host still decided who may open the screen when the HTML was rendered.

What stays a document

On a public blog post or marketing page, you are reading HTML for people and crawlers. On a hub, chrome — who is signed in, which modules are on, which roles may open this screen — is decided when the page renders. That is the gate you already passed when the list or compose form appeared.

What becomes a small app inside the page

Once the document is open, lists page, filter, and refresh without a full postback. Compose hubs keep a long draft on screen while save talks to /api. The counter till is still a Razor layout with a dedicated script and JSON endpoints. Charts pull aggregates after the shell is up. The rule is the same: enhance the document; do not replace the host with a second frontend.

Components without a client framework

When people ask where the components are, the answer is mostly Razor partials and small JavaScript modules.

Partials are the reuse layer: top bar, side nav, module page titles, CRM create forms that open in a modal, file-attachment blocks, and the same idea across inventory and project hubs. Layouts pull those partials once so every CRM screen does not reinvent the nav. Use only built-in ASP.NET Core tag helpers for page links, form fields, and cache-busting on static files.

A ViewComponent appears only when chrome itself needs a little data — for example a first-run welcome modal. The product does not rely heavily on ViewComponents for everyday screens.

On the script side, shared helpers own same-origin fetch, path-base-aware URLs, 401 to login, and ProblemDetails-friendly errors. Feature scripts own the screen: fill a table body, open a picker, drive the till. CRM pickers and create-form partials plus that shared API helper are the “widgets.” There is no client router that must know every product surface. Module navigation stays a full document; the component system stays boring and searchable in the repo.

Why it matters: Ten modules can share auth and chrome without one SPA bundle that has to understand CRM, warehouse, blog, and POS at once.

How the first paint gets its rows

Most hubs follow one of two patterns for operational data. Pick the right one per screen, not per ideology. Auth and account forms stay on a classic form POST — useful, but outside the hub-grid story.

Pattern When Example
Empty shell + API call Lists, filters, grids CRM organizations, inventory products
Server hydrate First paint already needs data Blog hub list, public reads, printables

Note: Login and password reset still use classic form POST. That is the right tool for auth — it is just not how hub grids load.

Empty shell and the API

Skip the page query; let JavaScript fetch from /api after the shell renders. The page model’s get handler authorizes and returns the document. The table body shows a loading row or nothing. Page JavaScript calls /api, gets JSON, and writes the rows. CRM organizations and inventory products both work that way: the Razor page does not run the list query; the API does. The operator still sees the right chrome and permissions on first paint. The data arrives a moment later without turning a failed save into a new HTML document — the Part 3 contract again.

CRM organizations: empty OnGet → GET /api/crm/organizations → tbody rows
Inventory products: same shape against /api/inventory/products

When to hydrate on the server

Run the list query in your page model so the data is already in the HTML. The blog hub is the clear example: categories and posts arrive with the document; compose and save still go through /api so the composer stays on screen. Public blog reads, storefront pages, and printables need HTML for people and crawlers, not a spinner waiting on JavaScript.

Blog hub: page query → HTML list → /api/blog/posts for save (composer stays)

Why it matters: Two patterns, one rule — always authorize before rendering; always leave the browser out of the SQL.

How the page gets data

Parameterized queries and DTOs

The browser asks /api for JSON. Behind that boundary, the controller authorizes, the service applies rules when the write needs them, and parameterized SQL on the server returns a DTO through EF Core. Occasional stored procedures cover tight board or reorder round-trips. Controllers and services call EF Core as needed — there is no repository layer everywhere — and the connection string never leaves the host.

Where the other stories live

Where schema ownership lives (Part 1), publish and lockdown (Part 2), and the save pipeline (Part 3) are separate chapters. This post focuses on one question: how does a grid on a Razor page get its rows? Answer: through same-origin /api, sometimes hydrated on first paint, always with tenant-aware SQL on the server. Part 5 is that tenant habit in full.

SQL runs on the server behind /api. It is not a front-end library.

How this sets the process apart

A typical process ships a SPA for the hub and a separate marketing site, then discovers two auth stories and two deploy trains. Lists either over-fetch on every navigation or hide permission failures inside client routes. Components mean a framework tree; SQL means whatever the BFF invented last quarter.

inveazy keeps one host and two response languages, then matches the front end to that split. Razor owns documents, gates, and composition through layouts and partials. Hub JS owns interaction against /api. SQL stays in controllers and services against the shared catalog. CRM, warehouse, blog, and POS stay one product without one client framework owning every click.

Enhance the page. Do not replace the product with a second frontend.

Questions we get about this shape

Why not a full SPA?

Because the product is many modules with deep links, server-side module flags, and a public HTML face on the same host. Users can still get SPA-like grids inside a page. That should not be the only way to open inventory or publish a blog post.

Why is the table empty on first paint?

On most operational lists the page authorizes the shell; the script loads the rows from /api. That keeps failed saves and list refreshes on the same document. Hubs that need HTML in the first response — blog hub list, public reads — query on the page model instead.

Where are the components?

Razor partials for shared chrome and forms; small JS modules for shared fetch and screen behavior. Not a client component framework as the product shell.

Does the browser run SQL?

No. The browser calls /api (or receives HTML the page already built). Parameterized SQL runs in the web application. The database project still owns the schema.

How does this relate to Part 3?

Part 3 is the host: Razor vs /api, JSON errors, cookies. This post is the screen half of the same contract — composition, progressive scripts, and the path from page to rows.

What this contract keeps in place

Hub pages are Razor documents composed from module layouts and shared partials. JavaScript enhances those documents against same-origin /api without replacing them. Components are partials and JS modules, not a second product shell. Most lists authorize first and fetch second; a few hydrate on the server when HTML must arrive with the document. SQL stays behind controllers and services, shaped for DTOs, tenant-aware by habit. The web application still does not migrate the database when a page loads.

Part 5 is multi-tenant design in practice: organization versus workspace, tenant context on every operational row, and why “fix the UI by cleaning data across tenants” is refused. The same Razor and /api host is what has to keep isolation as habit — including on the queries that feed these grids.

The layout owns the shell. The script owns the grid. The host owns the SQL.


What’s next

Next in the series: Multi-tenant design in practice — organization versus workspace, TenantId as habit, public content versus hub data, and isolation that survives a confusing screen. Operator walkthroughs live in the separate how-to series.