Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.

CapabilityPlain ReactNext.js
RoutingNone (add React Router)Built-in (file-based)
Server-side renderingNoneBuilt-in
API endpointsNone (need a separate server)Built-in (route.ts)
Data fetchingManual (useEffect + fetch)Structured patterns (async/await in components)
Build optimizationManual (configure webpack)Automatic
MiddlewareNoneBuilt-in (middleware.ts)
Code splittingManualAutomatic

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' in app/)
  • FavoriteButton is a Client Component, imported into the [id] product detail page
  • middleware.ts uses console.log(request.nextUrl.pathname) and a redirect
  • API routes handle only GET and POST, return NextResponse.json()
  • TypeScript throughout — define a Product interface in types/index.ts

Quick Reference

ConceptNext.jsASP.NET Equivalent
Route to a pageapp/products/page.tsxPages/Products/Index.cshtml
Dynamic segmentapp/products/[id]/page.tsxPages/Products/{id}.cshtml
Catch-all routeapp/[...slug]/page.tsx{*path} wildcard route
Shared layoutapp/layout.tsx_Layout.cshtml
Nested layoutapp/products/layout.tsxNested _Layout.cshtml (manual)
Loading stateapp/products/loading.tsxManual spinner via partial view
Error boundaryapp/products/error.tsxtry/catch + error partial
404 pageapp/not-found.tsx404.cshtml / UseStatusCodePages
API endpointapp/api/users/route.ts[ApiController] / Minimal API
Middlewaremiddleware.tsProgram.cs middleware pipeline
Server-only codeServer Component (default)Controller / Razor Page codebehind
Client interactivity'use client' + hooksBlazor / JavaScript
Static generationDefault fetch (no cache)Pre-rendered static HTML
ISR (periodic rebuild)next: { revalidate: N }OutputCache with sliding expiry
Dynamic SSRcache: 'no-store'No caching — fresh on each request
Environment config.env.localappsettings.Development.json
Server-only env varVARIABLE_NAME (no prefix)ConnectionStrings, secrets
Browser env varNEXT_PUBLIC_VARPublic config / page model output
Static assetspublic/ folderwwwroot/
TypeScript configtsconfig.json.csproj / global.json
Start dev servernpm run devdotnet watch
Production buildnpm run builddotnet publish

Further Reading