Shortcuts
BlogSeptember 15, 2025

Mohamed Elbarry
Building Scalable Next.js Applications
I’ve seen Next.js projects that put everything in getServerSideProps or fetched on the client with no caching, and the first real traffic spike showed 3s TTFB. Scaling here is mostly about using the right rendering mode and cache boundaries so the app stays fast without over-complicating the codebase. Here’s how I structure and tune Next.js apps. For Lumin Search we needed fast search and fresh catalog data. With the App Router I group routes, keep API routes under app/api, and put shared UI and logic in predictable places. Route groups like (auth) keep the URL clean while grouping layout and code.
Tsx
src/
├── app/
│   ├── (auth)/
│   ├── api/
│   └── globals.css
├── components/
│   ├── ui/
│   ├── forms/
│   └── layout/
├── lib/
├── hooks/
├── types/
└── utils/
For server-side DB access I use a connection pool so we don’t open a new connection per request. I set a reasonable max and timeouts so the app degrades gracefully under load.
Tsx
import { Pool } from 'pg';

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 20,
  idleTimeoutMillis: 30000,
  connectionTimeoutMillis: 2000,
});

export default pool;
Next.js caches fetch by default in the App Router. Under the hood there are several layers: Router Cache (client-side, visited routes), Full Route Cache (built output), Data Cache (fetch results persisted across requests), and Request Memoization (dedupes identical fetches in a single render). I use revalidate for data that can be stale for a bit (e.g. 1 hour) so we don’t hit the origin on every request. For invalidation you can use revalidatePath or revalidateTag so specific routes or tagged fetches get fresh data on demand. The Next.js caching docs and caching and revalidating break down the layers—tbh it’s easy to get confused until you’ve read that once.
Tsx
export async function GET() {
  const users = await fetch('https://api.example.com/users', {
    next: { revalidate: 3600 }
  });

  return Response.json(users);
}
For routes or segments that must be dynamic I set export const dynamic = 'force-dynamic' so they’re not cached. I use the Next.js Image component for automatic optimization and lazy loading. For heavy components that aren’t above the fold I load them with dynamic and a loading state so the initial JS stays small.
Tsx
import Image from 'next/image';

<Image
  src='/hero-image.jpg'
  alt='Hero image'
  width={800}
  height={600}
  priority
  placeholder='blur'
  blurDataURL='data:image/jpeg;base64,...'
/>
Tsx
const HeavyComponent = dynamic(() => import('./HeavyComponent'), {
  loading: () => <p>Loading...</p>,
  ssr: false
});
I wrap parts of the tree in error boundaries so one failing component doesn’t take down the whole page. I log the error and show a fallback; in production I sometimes report to an error service.
Tsx
export class ErrorBoundary extends Component<Props, State> {
  static getDerivedStateFromError(): State {
    return { hasError: true };
  }

  componentDidCatch(error: Error, errorInfo: any) {
    console.error('Error caught by boundary:', error, errorInfo);
  }

  render() {
    if (this.state.hasError) {
      return <h1>Something went wrong.</h1>;
    }
    return this.props.children;
  }
}
Use the App Router and group routes and layout logically. Prefer server components and use revalidate or segment config for caching so the app scales without adding a separate cache layer too early. Use Image and dynamic() to keep the initial bundle and payload small. Add error boundaries around critical sections. One thing to try: run Lighthouse on a key page and fix the largest bottleneck (often images or uncached data). Next.js docs and Caching are good next reads. Hope that helps. I'm currently looking for new challenges in the AI and Full Stack space. If you're building something interesting, let's chat.
Share this post: