Next.js: The ASP.NET MVC of React
For .NET engineers who know: ASP.NET Core MVC, Razor Pages, Minimal APIs, ASP.NET Core middleware You’ll learn: How Next.js brings server-side rendering, file-based routing, API endpoints, and middleware to React — mapping each concept to the ASP.NET Core feature you already know Time: 15-20 min read
The .NET Way (What You Already Know)
ASP.NET Core MVC is a convention-driven framework. Drop a file in the Controllers/ folder, inherit from Controller, name a method correctly, and the framework routes requests to it. Drop a .cshtml file in Views/, follow the naming convention, and the framework knows how to render it. The framework inspects your project structure and wires things up.
Razor Pages sharpens this further. A Pages/Products/Index.cshtml file with a corresponding Index.cshtml.cs page model handles GET /products automatically. The file path is the route. There is no separate routing configuration to maintain.
ASP.NET Core’s pipeline is middleware-based. You register middleware in Program.cs, and every request passes through each piece in order — authentication, CORS, routing, static files, and finally your application logic. Middleware can short-circuit the pipeline by not calling next().
// Program.cs — ASP.NET Core pipeline
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
var app = builder.Build();
app.UseHttpsRedirection(); // middleware
app.UseStaticFiles(); // middleware
app.UseAuthentication(); // middleware
app.UseAuthorization(); // middleware
app.MapControllers(); // routing
app.Run();
// Razor Pages file structure → URL mapping
Pages/
Index.cshtml → GET /
Products/
Index.cshtml → GET /products
Details.cshtml → GET /products/details
[id].cshtml → GET /products/5 (dynamic segment)
Account/
Login.cshtml → GET /account/login
Next.js is built on exactly these mental models, applied to React.
The Next.js Way
What Next.js Adds to React
Plain React is a UI library — it renders components but provides nothing for routing, server rendering, data fetching, or API endpoints. Every React application needs a framework to supply these things. Next.js is the dominant choice.
| Capability | Plain React | Next.js |
|---|---|---|
| Routing | None (add React Router) | Built-in (file-based) |
| Server-side rendering | None | Built-in |
| API endpoints | None (need a separate server) | Built-in (route.ts) |
| Data fetching | Manual (useEffect + fetch) | Structured patterns (async/await in components) |
| Build optimization | Manual (configure webpack) | Automatic |
| Middleware | None | Built-in (middleware.ts) |
| Code splitting | Manual | Automatic |
Think of it exactly like the difference between a class library and ASP.NET Core: the library gives you building blocks, the framework gives you a working application.
Project Setup
npx create-next-app@latest my-app --typescript --tailwind --app
cd my-app
npm run dev # starts on http://localhost:3000
The --app flag selects the App Router (Next.js 13+). This is the current architecture. The older Pages Router still works but is in maintenance mode — equivalent to Web Forms vs. MVC.
App Router: File-Based Routing
The App Router lives in the app/ directory. The file structure is the route structure.
app/
layout.tsx → root layout (wraps every page)
page.tsx → GET /
loading.tsx → loading UI for /
error.tsx → error boundary for /
not-found.tsx → 404 for /
products/
page.tsx → GET /products
layout.tsx → layout for /products/* (nested layout)
loading.tsx → loading UI for /products
[id]/
page.tsx → GET /products/123
edit/
page.tsx → GET /products/123/edit
api/
users/
route.ts → GET/POST /api/users (API endpoint)
users/[id]/
route.ts → GET/PUT/DELETE /api/users/123
This maps directly to how Razor Pages works. Compare them:
// Razor Pages → Next.js App Router
Pages/Index.cshtml → app/page.tsx
Pages/Products/Index.cshtml → app/products/page.tsx
Pages/Products/Details.cshtml → app/products/[id]/page.tsx
_Layout.cshtml (shared layout) → app/layout.tsx
Partial views → Shared components in components/
Route segments in square brackets are dynamic — identical to the {id} route template syntax in ASP.NET:
// app/products/[id]/page.tsx
// Handles: /products/1, /products/abc, /products/any-slug
interface PageProps {
params: { id: string }
searchParams: { [key: string]: string | string[] | undefined }
}
export default function ProductPage({ params, searchParams }: PageProps) {
// params.id is the dynamic segment: '123'
// searchParams.sort is ?sort=price
return <h1>Product {params.id}</h1>
}
Catch-all routes use [...slug] — equivalent to ASP.NET’s {*path} wildcard:
app/docs/[...slug]/page.tsx → /docs/intro, /docs/api/users, /docs/a/b/c
layout.tsx: The _Layout.cshtml Equivalent
Every directory can have a layout.tsx that wraps all pages beneath it. Layouts are nested — a child directory’s layout wraps inside the parent layout. This is more composable than ASP.NET’s single _Layout.cshtml:
// app/layout.tsx — root layout, wraps every page
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'My App',
description: 'Built with Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>
<nav>Global navigation</nav>
<main>{children}</main> {/* ← equivalent to @RenderBody() */}
<footer>Footer</footer>
</body>
</html>
)
}
// app/dashboard/layout.tsx — nested layout for /dashboard/*
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
return (
<div className="dashboard-container">
<aside>Sidebar</aside>
<section>{children}</section>
</div>
)
}
A request to /dashboard/settings renders: RootLayout > DashboardLayout > SettingsPage. ASP.NET’s nested layouts are possible but require explicit @RenderSection plumbing. Next.js handles the nesting automatically.
loading.tsx and error.tsx: Built-In UI States
Next.js has dedicated file conventions for loading and error states — concepts that require manual work in ASP.NET:
// app/products/loading.tsx
// Shown automatically while the page.tsx component is loading
export default function Loading() {
return <div className="skeleton-loader">Loading products...</div>
}
// app/products/error.tsx
// Shown when any error is thrown in page.tsx or its children
// Must be a Client Component (error boundaries require client-side React)
'use client'
interface ErrorProps {
error: Error & { digest?: string }
reset: () => void
}
export default function ProductsError({ error, reset }: ErrorProps) {
return (
<div>
<h2>Failed to load products</h2>
<p>{error.message}</p>
<button onClick={reset}>Try again</button>
</div>
)
}
This is equivalent to try/catch in a Razor Page’s OnGetAsync combined with a partial view for the error state — but declarative and automatic.
Server Components vs. Client Components: The Key Architectural Decision
This is the concept that most surprises .NET engineers, and the one that matters most for performance.
By default, every component in app/ is a Server Component. It runs only on the server — during the build (for static pages) or on each request (for dynamic pages). It can do things a browser component cannot: read files, query databases directly, use secret environment variables, make server-to-server API calls.
A Client Component runs in the browser. It can use useState, useEffect, event handlers, browser APIs (window, localStorage), and third-party components that need the DOM. To mark a component as a client component, add 'use client' at the top of the file.
// app/products/page.tsx — Server Component (no 'use client' = server by default)
// This runs on the server. The browser never downloads this code.
// Think of it like a Razor Page's OnGetAsync — runs server-side, returns rendered HTML.
async function getProducts() {
// Direct database query here is fine — this code never runs in the browser
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 60 } // cache for 60 seconds — explained below
})
return res.json()
}
export default async function ProductsPage() {
const products = await getProducts() // await works directly in Server Components
return (
<div>
<h1>Products</h1>
<ul>
{products.map((p: { id: number; name: string; price: number }) => (
<li key={p.id}>{p.name} — ${p.price}</li>
))}
</ul>
</div>
)
}
// components/AddToCartButton.tsx — Client Component
// This needs an onClick handler, so it must run in the browser
'use client'
import { useState } from 'react'
interface Props {
productId: number
}
export function AddToCartButton({ productId }: Props) {
const [added, setAdded] = useState(false)
async function handleAdd() {
await fetch('/api/cart', {
method: 'POST',
body: JSON.stringify({ productId }),
})
setAdded(true)
}
return (
<button onClick={handleAdd} disabled={added}>
{added ? 'Added' : 'Add to Cart'}
</button>
)
}
A Server Component can import and render a Client Component. A Client Component cannot import a Server Component (the server code would ship to the browser). This creates a tree: Server Components form the “shell,” Client Components are the interactive islands.
Mental model for .NET engineers: Server Components are like Razor Pages markup — they render HTML on the server and never ship logic to the browser. Client Components are like Blazor WebAssembly — they download to the browser and run there. The difference is that in Next.js, you mix them in the same tree.
API Routes: route.ts as Minimal APIs
The route.ts file convention creates HTTP endpoints — equivalent to ASP.NET Minimal API handlers or [ApiController] methods.
// app/api/products/route.ts
// Handles GET /api/products and POST /api/products
import { NextRequest, NextResponse } from 'next/server'
// Named exports for each HTTP method — like [HttpGet] / [HttpPost] attributes
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url)
const category = searchParams.get('category') // ?category=electronics
const products = await fetchProductsFromDb(category)
return NextResponse.json(products) // equivalent to return Ok(products)
}
export async function POST(request: NextRequest) {
const body = await request.json()
// Validate
if (!body.name || !body.price) {
return NextResponse.json(
{ error: 'name and price are required' },
{ status: 400 } // equivalent to return BadRequest(...)
)
}
const product = await createProduct(body)
return NextResponse.json(product, { status: 201 }) // return Created(...)
}
// app/api/products/[id]/route.ts
// Handles GET/PUT/DELETE /api/products/:id
import { NextRequest, NextResponse } from 'next/server'
interface RouteContext {
params: { id: string }
}
export async function GET(request: NextRequest, { params }: RouteContext) {
const product = await getProductById(Number(params.id))
if (!product) {
return NextResponse.json({ error: 'Not found' }, { status: 404 })
}
return NextResponse.json(product)
}
export async function PUT(request: NextRequest, { params }: RouteContext) {
const body = await request.json()
const updated = await updateProduct(Number(params.id), body)
return NextResponse.json(updated)
}
export async function DELETE(_request: NextRequest, { params }: RouteContext) {
await deleteProduct(Number(params.id))
return new NextResponse(null, { status: 204 }) // No Content
}
Comparison with ASP.NET Minimal API:
// ASP.NET Minimal API
app.MapGet("/api/products", async (string? category, IProductService svc) =>
{
var products = await svc.GetProductsAsync(category);
return Results.Ok(products);
});
app.MapPost("/api/products", async (CreateProductDto dto, IProductService svc) =>
{
if (string.IsNullOrWhiteSpace(dto.Name))
return Results.BadRequest("name is required");
var product = await svc.CreateAsync(dto);
return Results.Created($"/api/products/{product.Id}", product);
});
The structure is almost identical. The main difference: Next.js routes are file-based (the URL comes from the folder path). ASP.NET routes are code-based (you declare the path in MapGet).
Middleware: middleware.ts
Next.js middleware intercepts requests before they reach any route or page — identical to ASP.NET Core middleware in Program.cs.
// middleware.ts — place at project root (alongside app/ and package.json)
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl
// Auth check — like app.UseAuthorization() combined with [Authorize]
if (pathname.startsWith('/dashboard')) {
const token = request.cookies.get('auth-token')?.value
if (!token) {
// Redirect to login — like returning a ChallengeResult
return NextResponse.redirect(new URL('/login', request.url))
}
}
// Add a custom header to all responses
const response = NextResponse.next()
response.headers.set('X-Frame-Options', 'DENY')
return response
}
// Which routes this middleware runs on — like [Authorize] on a controller
export const config = {
matcher: [
'/dashboard/:path*',
'/api/admin/:path*',
// Exclude static files and Next.js internals
'/((?!_next/static|_next/image|favicon.ico).*)',
],
}
Unlike ASP.NET middleware, Next.js middleware runs at the Edge — it executes close to the user before hitting your server. It cannot use Node.js APIs (fs, crypto, etc.) or access a database. It is limited to the Edge Runtime, which is a subset of the Web APIs. Use it for auth redirects, request rewriting, and header manipulation — not business logic.
Data Fetching Patterns
Next.js has three data rendering models, directly analogous to ASP.NET rendering strategies:
Static Generation (SSG) — like pre-generating HTML from a build step (comparable to pre-rendering in Blazor or publishing a static site):
// No dynamic behavior — this page is built once at deploy time
// Like a completely static HTML file
export default async function AboutPage() {
const content = await fetch('https://cms.example.com/about').then(r => r.json())
return <article>{content.body}</article>
}
Incremental Static Regeneration (ISR) — regenerate pages in the background on a timer:
async function getData() {
const res = await fetch('https://api.example.com/products', {
next: { revalidate: 3600 } // rebuild this page every hour in the background
})
return res.json()
}
Dynamic (Server-Side Rendering) — like a Razor Page’s OnGetAsync — renders fresh HTML on every request:
async function getData() {
const res = await fetch('https://api.example.com/cart', {
cache: 'no-store' // never cache — render fresh every request
})
return res.json()
}
The general heuristic: static for marketing pages, ISR for product catalogs that change occasionally, dynamic for authenticated/personalized content.
Environment Variables
Next.js environment variables work like appsettings.json + IConfiguration:
# .env.local (like appsettings.Development.json — not committed to git)
DATABASE_URL=postgresql://localhost:5432/mydb
NEXTAUTH_SECRET=supersecret
# Variables prefixed NEXT_PUBLIC_ are included in browser bundles
# Without the prefix, they are server-only (like ConnectionStrings in appsettings.json)
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_...
// Server Component or API route — all env vars available
const dbUrl = process.env.DATABASE_URL // works
// Client Component — only NEXT_PUBLIC_ vars available
const stripeKey = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY // works
const secret = process.env.NEXTAUTH_SECRET // undefined in browser — intentional
This maps to the ASP.NET pattern where ConnectionStrings stay server-side in configuration and are never exposed to the client, while public keys (like a maps API key) are in appsettings.json and may be included in page output.
Project Structure: Next.js Mapped to ASP.NET MVC
ASP.NET MVC Next.js (App Router)
────────────────────────────────────────────────────
Controllers/ app/api/
ProductsController.cs products/route.ts
UsersController.cs users/route.ts
Views/ app/
Products/ products/
Index.cshtml page.tsx
Details.cshtml [id]/page.tsx
Create.cshtml new/page.tsx
Shared/ components/ (convention)
_Layout.cshtml layout.tsx (in app/)
_ProductCard.cshtml ProductCard.tsx
Models/ types/ or lib/types.ts
Product.cs (TypeScript interfaces)
CreateProductDto.cs
Services/ lib/ or services/
IProductService.cs product-service.ts
ProductService.cs (no interface needed — TS structural typing)
wwwroot/ public/
css/ (static files served as-is)
js/
images/
Program.cs middleware.ts + next.config.ts
appsettings.json .env.local / .env.production
Key Differences
No Dependency Injection Container
ASP.NET Core has a built-in IoC container. You register services in Program.cs and inject them via constructors. Next.js has no equivalent. You import functions directly. Inversion of control is achieved through module boundaries and passing dependencies as function arguments or React context — not a container.
// ASP.NET — DI container
builder.Services.AddScoped<IProductService, ProductService>();
// Injected via constructor
public ProductsController(IProductService productService) { ... }
// Next.js — direct import
import { getProducts } from '@/lib/product-service'
// Called directly
const products = await getProducts()
For testing, you pass mock implementations as arguments or use jest module mocking — no mock container required.
No Global HttpContext — Requests Are Passed as Parameters
In ASP.NET, HttpContext is ambient — available anywhere via IHttpContextAccessor. In Next.js, the NextRequest object is passed explicitly to route handlers and middleware. Server Components access request data through Next.js functions like cookies() and headers() from next/headers.
import { cookies, headers } from 'next/headers'
export default async function ProfilePage() {
const cookieStore = cookies()
const token = cookieStore.get('auth-token')
const headersList = headers()
const userAgent = headersList.get('user-agent')
return <div>Profile page</div>
}
'use client' Is a Module-Level Decision
Once a file has 'use client', every component in that file becomes a Client Component. This boundary propagates — all children of a Client Component are also treated as client-side, unless you pass Server Component output as children. Plan your component tree so that interactive leaf components are small Client Components and data-fetching parents are Server Components.
Gotchas for .NET Engineers
Gotcha 1: async/await in Server Components works; in Client Components it does not
The most common early mistake. Server Components can be async functions. Client Components cannot use await at the top level of a component (you cannot write const data = await fetch(...) directly in a Client Component). Client Components use useEffect + useState for data fetching, or a library like react-query / SWR.
// CORRECT — Server Component
export default async function ProductsPage() {
const products = await getProducts() // fine
return <ul>...</ul>
}
// WRONG — Client Component
'use client'
export default async function Counter() { // async client component is invalid
const data = await fetch('/api/data') // do not do this
}
// CORRECT — Client Component with data fetching
'use client'
import { useState, useEffect } from 'react'
export default function Counter() {
const [data, setData] = useState(null)
useEffect(() => {
fetch('/api/data').then(r => r.json()).then(setData)
}, [])
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>
}
Gotcha 2: The params object in page and route components will become async in Next.js 15
Starting in Next.js 15, params and searchParams in page.tsx and route.ts are Promises, not plain objects. If you are on Next.js 15+, you must await params:
// Next.js 14 and earlier
export default function Page({ params }: { params: { id: string } }) {
return <div>{params.id}</div>
}
// Next.js 15+
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
return <div>{id}</div>
}
This change caught many teams mid-upgrade. Check your Next.js version and the upgrade guide when migrating.
Gotcha 3: Middleware runs on the Edge Runtime — no Node.js APIs
If you try to import a Node.js module (like fs, path, crypto, or a database client) in middleware.ts, the build will fail or the feature will be silently broken. Middleware runs in the Edge Runtime, which only exposes a subset of Web APIs. Do your database access and business logic in Server Components and API routes, not middleware.
// middleware.ts
import { db } from '@/lib/database' // WRONG — database clients use Node.js APIs
// Use middleware only for things the Edge Runtime supports:
// - cookies(), headers()
// - NextResponse.redirect(), NextResponse.rewrite()
// - JWT verification (using the Web Crypto API, not Node's crypto module)
// - Checking a feature flag from a lightweight store (Vercel Edge Config, etc.)
Gotcha 4: Environment variables without NEXT_PUBLIC_ are not available in the browser
A common confusion: you define API_KEY=abc in .env.local, use it in a Server Component, and it works. Then you copy the same code to a Client Component and get undefined. The variable never shipped to the browser. This is intentional security behavior — not a bug.
// .env.local
DATABASE_URL=postgresql://... // server-only
NEXT_PUBLIC_GOOGLE_MAPS_KEY=... // included in browser bundle (safe to expose)
// Always ask: would it be OK if this value appeared in the browser's source?
// If no → no NEXT_PUBLIC_ prefix
// If yes → add NEXT_PUBLIC_ prefix
Gotcha 5: fetch is extended by Next.js — not the standard browser API
In Server Components, fetch is the standard Web API, but Next.js wraps it to add caching directives (next: { revalidate: N }, cache: 'no-store'). This is a Next.js-specific extension. When you move code from a Server Component to a Client Component, these caching options are silently ignored. Never write data-fetching code with next: { revalidate } options in a Client Component — it will appear to work but will not cache as expected.
Gotcha 6: The app/ and pages/ directories cannot coexist in a real project without careful configuration
The old Pages Router (pages/) and new App Router (app/) can technically coexist to support incremental migration, but they have different semantics, different data-fetching patterns, and different layout systems. Mixing them for features (not migration) creates confusion. On a new project, use only app/. On an existing project with pages/, migrate incrementally by moving routes one at a time.
Hands-On Exercise
Build a small Next.js product catalog with the following structure. This exercises file-based routing, Server Components, a Client Component island, an API route, and middleware.
Structure to implement:
app/
layout.tsx → Global layout with a nav bar
page.tsx → Home page: static, shows "Welcome"
products/
page.tsx → Server Component: fetches and lists products from the API route
[id]/
page.tsx → Server Component: fetches one product by id
api/
products/
route.ts → GET: return a hardcoded list; POST: log body and return 201
[id]/
route.ts → GET: return single product or 404
middleware.ts → Log every request path to console; redirect /old-products to /products
components/
FavoriteButton.tsx → Client Component with useState to toggle a favorite icon
Requirements:
- All pages are Server Components (no
'use client'inapp/) FavoriteButtonis a Client Component, imported into the[id]product detail pagemiddleware.tsusesconsole.log(request.nextUrl.pathname)and a redirect- API routes handle only GET and POST, return
NextResponse.json() - TypeScript throughout — define a
Productinterface intypes/index.ts
Quick Reference
| Concept | Next.js | ASP.NET Equivalent |
|---|---|---|
| Route to a page | app/products/page.tsx | Pages/Products/Index.cshtml |
| Dynamic segment | app/products/[id]/page.tsx | Pages/Products/{id}.cshtml |
| Catch-all route | app/[...slug]/page.tsx | {*path} wildcard route |
| Shared layout | app/layout.tsx | _Layout.cshtml |
| Nested layout | app/products/layout.tsx | Nested _Layout.cshtml (manual) |
| Loading state | app/products/loading.tsx | Manual spinner via partial view |
| Error boundary | app/products/error.tsx | try/catch + error partial |
| 404 page | app/not-found.tsx | 404.cshtml / UseStatusCodePages |
| API endpoint | app/api/users/route.ts | [ApiController] / Minimal API |
| Middleware | middleware.ts | Program.cs middleware pipeline |
| Server-only code | Server Component (default) | Controller / Razor Page codebehind |
| Client interactivity | 'use client' + hooks | Blazor / JavaScript |
| Static generation | Default fetch (no cache) | Pre-rendered static HTML |
| ISR (periodic rebuild) | next: { revalidate: N } | OutputCache with sliding expiry |
| Dynamic SSR | cache: 'no-store' | No caching — fresh on each request |
| Environment config | .env.local | appsettings.Development.json |
| Server-only env var | VARIABLE_NAME (no prefix) | ConnectionStrings, secrets |
| Browser env var | NEXT_PUBLIC_VAR | Public config / page model output |
| Static assets | public/ folder | wwwroot/ |
| TypeScript config | tsconfig.json | .csproj / global.json |
| Start dev server | npm run dev | dotnet watch |
| Production build | npm run build | dotnet publish |
Further Reading
- Next.js App Router documentation — The official guide. Start with “Getting Started” and “Routing Fundamentals”
- Server and Client Components — The most important conceptual section in the Next.js docs for developers coming from a server-centric background
- Data Fetching patterns — Covers static, ISR, and dynamic patterns with examples
- Next.js middleware — Edge Runtime capabilities and limitations
- Next.js with TypeScript — Official TypeScript configuration and type utilities
- Vercel deployment guide — Next.js is made by Vercel; their hosting has the deepest feature support. Render (our stack’s hosting platform) supports Next.js as well