Building Scalable Web Applications with Next.js
Architecture decisions that hold up when your app grows

"Scalable" gets thrown around a lot. Most of the time it means "we'll figure it out later." This post is about the decisions you make early that either age well or become the thing you're refactoring six months in.
I've built and shipped Next.js applications across healthcare, productivity, and automation — ranging from MVPs that needed to move fast to a clinical platform where data integrity and access control weren't optional. The patterns here are what I reach for by default, and why.
App Router over Pages Router — and commit to it
If you're starting a new Next.js project in 2026, use the App Router. The Pages Router isn't going away, but the ecosystem has moved on, and the App Router's model of server components, layouts, and streaming is more aligned with how modern apps actually need to behave.
The key mental shift: components are server-rendered by default. You opt into client-side interactivity explicitly with "use client". This matters for performance and for security — sensitive data transformations stay on the server without any extra effort.
// app/dashboard/page.tsx — server component by default
// No "use client" — this runs on the server
import { getProjects } from '@/lib/db'
export default async function DashboardPage() {
const projects = await getProjects() // direct DB call, no API round-trip
return <ProjectList projects={projects} />
}
// components/project-filter.tsx — only interactive piece
"use client"
import { useState } from 'react'
export function ProjectFilter({ onFilter }: { onFilter: (q: string) => void }) {
const [query, setQuery] = useState('')
// ...
}
Keep "use client" as far down the component tree as possible. Wrapping an entire page in a client component to handle one button click is a common mistake that kills performance.
Route structure is architecture
How you structure your routes reflects how you think about your application. The App Router's nested layouts make it easy to co-locate logic with the routes that need it.
A structure I come back to for SaaS applications:
app/
├── (marketing)/ # public pages — different layout
│ ├── page.tsx # homepage
│ ├── about/
│ └── pricing/
├── (app)/ # authenticated app — shared layout with nav
│ ├── layout.tsx # auth guard lives here
│ ├── dashboard/
│ ├── projects/
│ │ ├── page.tsx # project list
│ │ └── [id]/
│ │ └── page.tsx # individual project
└── api/ # API routes
Route groups (the folders in parentheses) let you share layouts without affecting the URL. Your marketing pages and your app shell don't need to share a layout — and they shouldn't.
Authentication at the layout level
Authentication should be handled at the layout, not scattered across individual pages. In the App Router, this means middleware for the redirect logic and a server-side check in your authenticated layout.
// middleware.ts
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
export function middleware(request: NextRequest) {
const token = request.cookies.get('auth-token')
const isAuthRoute = request.nextUrl.pathname.startsWith('/app')
if (isAuthRoute && !token) {
return NextResponse.redirect(new URL('/login', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/app/:path*']
}
// app/(app)/layout.tsx
import { redirect } from 'next/navigation'
import { getSession } from '@/lib/auth'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const session = await getSession()
if (!session) redirect('/login')
return (
<div className="app-shell">
<Sidebar user={session.user} />
<main>{children}</main>
</div>
)
}
Two layers of protection: middleware handles the redirect before the page renders, the layout handles the server-side check. Never rely on client-side checks for access control.
Data fetching: fetch at the right level
One of the App Router's biggest wins is eliminating prop drilling for data. Server components can fetch their own data directly.
// Before — data fetched at the top, drilled down
// app/projects/page.tsx
export default async function ProjectsPage() {
const projects = await fetchProjects()
return <ProjectList projects={projects} />
}
// ProjectList passes data to ProjectCard, which passes to ProjectStatus...
// Better — each component fetches what it needs
// Next.js deduplicates identical fetch calls automatically
// app/projects/page.tsx
export default function ProjectsPage() {
return (
<Suspense fallback={<ProjectsSkeleton />}>
<ProjectList />
</Suspense>
)
}
// components/project-list.tsx (server component)
async function ProjectList() {
const projects = await fetchProjects() // fetched here, used here
return projects.map(p => <ProjectCard key={p.id} project={p} />)
}
Wrapping in Suspense gives you streaming — the page shell renders immediately, data loads progressively. Users see something faster even if the full content takes longer.
API layer: Next.js routes vs. external backend
A question I get asked often: should API logic live in Next.js API routes or a separate backend?
My rule of thumb:
| Use Next.js API routes for | Use a separate backend (Django/FastAPI) for |
|---|---|
| Simple CRUD, auth callbacks | Complex business logic |
| Webhook handlers | Background jobs, queues |
| Data transformation for the frontend | Multiple clients (web + mobile + third-party) |
| Prototyping | Regulated environments (HIPAA, PCI) |
For regulated or compliance-sensitive environments, a dedicated FastAPI backend is the right call. The compliance boundary is cleaner, audit logging is centralised, and you're not constrained by Next.js's serverless execution model for long-running operations.
For smaller projects and MVPs, Next.js API routes are fine — they reduce infrastructure complexity when you're moving fast.
Environment and secrets management
Non-negotiable regardless of app size: secrets never go in your codebase.
# .env.local — never committed
DATABASE_URL=postgresql://...
API_SECRET_KEY=...
NEXT_PUBLIC_APP_URL=http://localhost:3000 # NEXT_PUBLIC_ prefix = exposed to browser
In production on AWS, I use Parameter Store:
# For the FastAPI backend — fetch secrets at startup
import boto3
def get_secret(name: str) -> str:
client = boto3.client('ssm', region_name='eu-west-1')
response = client.get_parameter(Name=name, WithDecryption=True)
return response['Parameter']['Value']
DATABASE_URL = get_secret('/prod/craftripple/database-url')
A rule worth following: if the secret ever touches the filesystem or logs, rotate it. No exceptions.
TypeScript: use it properly
TypeScript with any everywhere is just JavaScript with extra steps. A few patterns that make the type system actually useful:
Type your API responses. Don't trust external data — validate it at the boundary.
// types/project.ts
export interface Project {
id: string
name: string
status: 'active' | 'archived' | 'draft'
createdAt: string
}
// Use Zod for runtime validation at the API boundary
import { z } from 'zod'
const ProjectSchema = z.object({
id: z.string(),
name: z.string().min(1),
status: z.enum(['active', 'archived', 'draft']),
createdAt: z.string().datetime(),
})
export type Project = z.infer<typeof ProjectSchema>
Use discriminated unions for state. Far better than booleans for loading/error/success states.
type FetchState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; message: string }
CI/CD: ship with confidence
Every project I deliver includes a working CI/CD pipeline. A basic GitHub Actions setup for a Next.js app:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- run: npm ci
- run: npm run type-check
- run: npm run lint
- run: npm run test
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
vercel-args: '--prod'
Type check, lint, and test before every deploy. A deploy that skips these steps is a liability.
What "scalable" actually means
Scalable doesn't mean "handles millions of users." For most applications, it means: new developers can understand the codebase, features can be added without breaking existing ones, and the system degrades gracefully under load.
The decisions that drive that outcome are the same ones covered here — clear component boundaries, authentication at the right layer, typed contracts between frontend and backend, and a CI/CD pipeline that catches problems before they reach production.
Build those habits from the start. They're much harder to retrofit.
Liban Abdullahi is the founder of CraftRipple Studio, a Brussels-based development studio building secure full-stack applications for startups and healthcare companies.
Work with CraftRipple Studio on your next project.