IMR Lopez Logo

Building Scalable and Faster Web Applications with Next.js

Learn how to build scalable and faster web applications using Next.js with best practices and performance optimization techniques.

Next.js has revolutionized the way we build web applications, offering a powerful framework that combines the best of React with server-side rendering capabilities, data fetching, and routing. In this post, we'll explore how you can leverage Next.js to build scalable web applications that perform well, rank high in search engines, and provide a great user experience.

Why Next.js?

Next.js provides several features that make it an excellent choice for building scalable web applications:

  • Server-side rendering (SSR) for improved SEO and performance
  • Static site generation (SSG) for blazing-fast page loads
  • Incremental Static Regeneration (ISR) for dynamic content with static benefits
  • Partial Pre Rendering (PPR) for rendering only dynamic parts of a page
  • API routes for building backend functionality
  • File-based routing for simplified navigation
  • Cache control and performance optimization features
  • Built-in TypeScript support for type safety
  • React server components for faster rendering

These features allow developers to create applications that can handle high traffic, deliver content quickly, and provide a seamless user experience across devices. This makes Next.js a preferred choice for businesses looking to scale their web presence effectively but doesn't means that Next.js is a Golden Hammer for every use case as developers still need to evaluate their specific needs and constraints before choosing a framework.

Most "Next.js is slow" reports aren't about Next.js. They're about rendering something per-request that could have been prerendered, or prerendering something that had to be per-request. Everything below is a variation on getting that boundary right.

Performance Optimization Techniques

To ensure your Next.js application is fast and scalable, consider implementing the following performance optimization techniques:

Config

Many developers overlook the importance of proper configuration of the entry point of their Next.js applications. By fine-tuning the next.config.ts file, you can enable features like image optimization, code splitting, and caching strategies that significantly enhance performance.

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  cacheComponents: true,
	reactCompiler: true, 
	typescript: {
		ignoreBuildErrors: true,
	}
}
export default nextConfig;

This configuration enables component caching and the React compiler, which can lead to faster builds and improved runtime performance, splitting code into static and dynamic parts of each page.

Image Optimization

Next.js provides built-in image optimization capabilities through the next/image component. By using this component, you can automatically serve optimized images in modern formats like WebP, which reduces load times and improves performance, but what if we go farther??

next.config.ts
import type { NextConfig } from 'next';

const nextConfig: NextConfig = {
  // Other configurations...
  images: {
    domains: ['example.com'], // Add your image domains here
    formats: ['image/avif', 'image/webp'], // Enable modern image formats
  },
  async headers() {
    return [
      {
				source: '/_next/image',
				headers: [
					{
						key: 'Cache-Control',
						value: 'public, max-age=31536000, immutable',
					},
				],
			},
    ];
  },
};
export default nextConfig;

This configuration not only enables modern image formats but also sets cache-control headers for optimized images, allowing browsers to cache them effectively and reduce load times on subsequent visits. You can also use a CDN to serve images faster across different geographical locations by reducing latency and improving load times for users worldwide.

Caching

Caching bugs are rarely "the cache didn't work" — they're "the cache worked longer than I meant." Decide the invalidation story before the TTL. A cached response also pins its headers, which is how a Content-Disposition or a stale locale can outlive the code that produced it.

On the latest versions of Next.js (16+), caching has been improved significantly with the introduction of cacheComponents and 3 new directives: use cache, use cache:private, and use cache:remote, as they came out from the framework itself some developers or the community didn't like them and I understand why and agree that they could have been better designed you can check Tanner Linsley blog about directives to get better understanding what I mean. But at the end of the day, they are here to stay and we need to learn how to use them properly to take advantage of their benefits in our applications. Proper caching strategies can significantly reduce server load and improve response times, especially for frequently accessed data.

app/actions.ts
import { cacheTag, cacheLife } from 'next/cache'

export async function getData() {
  'use cache'
  cacheTag('my-data')
  cacheLife('hours')
  const data = await fetch('/api/data')
  return data
}

In this example, the getData function uses the use cache directive along with cacheTag and cacheLife to cache the fetched data for one hour. This reduces the number of requests made to the server for the same data, improving performance.

The cacheLife function allows you to specify the cache profile that comes with predefined configurations like stale, revalidate and expire to help you manage how long data should be cached based on your application's needs.

ProfileUse Casestalerevalidateexpire
defaultStandard content5 minutes15 minutes1 year
secondsReal-time data30 seconds1 second1 minute
minutesFrequently updated content5 minutes1 minute
hoursContent updated multiple times per day5 minutes1 hour1 day
daysContent updated daily5 minutes1 day
weeksContent updated weekly5 minutes1 week30 days
maxStable content that rarely changes5 minutes30 days1 year

You can create custom profiles as well if none of the predefined ones fit your needs.

next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
  cacheLife: {
    shortLived: {
      stale: 10, // 10 seconds
      revalidate: 5, // 5 seconds
      expire: 60, // 1 minute
    },
  },
}
export default nextConfig;

This configuration defines a custom cache profile called shortLived, which can be used in your application to cache data that changes frequently.

Api Routes

Next.js API routes allow you to build backend functionality directly within your Next.js application but the file-based routing system can lead to scalability issues as your application grows. To mitigate this, consider using another approach such as using a dedicated backend framework of rpcs services.

Using a Dedicated Backend framework inside Next.js

The reason to mount a real HTTP framework inside Next rather than hand-rolling route handlers: contracts. A typed contract at the boundary fails loudly when a handler drifts from it, which matters more when handlers are generated than when they're typed by hand.

While Next.js API routes are convenient for small to medium-sized applications, they may not be the best choice for larger applications with complex backend logic. In such cases, consider using a dedicated backend framework like Elysia or Hono to handle your backend functionality.

Elysia

src/app/api/[[...elysia]]/route.ts
import { Elysia } from 'elysia'

const app = new Elysia({ prefix: '/api' })
.get('/', 'Hello Nextjs')
.post(
  '/user',
  ({ body }) => body,
  {
    body: treaty.schema('User', {
      name: 'string'
    })
  }
)

export type app = typeof app 

export const GET = app.fetch
export const POST = app.fetch
// Add other HTTP methods as needed

That app type export is useful cause it allows you to use a kind of rpc approach in your Next.js app like this:

Hono

src/app/api/[[...hono]]/route.ts
import { Hono } from 'hono'
import { handle } from 'hono/vercel'

const app = new Hono().basePath('/api')

app.get('/hello', (c) => {
  return c.json({
    message: 'Hello Next.js!',
  })
})

export type app = typeof app 

export const GET = handle(app)
export const POST = handle(app)
// Add other HTTP methods as needed

As with Elysia, you can export the app type to use it in a rpc-like manner in your Next.js application.

Using RPC Services

Another approach to building a scalable backend for your Next.js application is to use RPC services like tRPC or oRpc. These services allow you to define your backend logic in a type-safe manner and call it directly from your frontend code without the need for RESTful API endpoints even when they use rest to communicate the back with the server on NextJs.

tRPC

src/server/trpc/init.ts
import { initTRPC } from '@trpc/server';
export const createContext = (props: {
  headers: Request
}) => ({ userId: 'demo-user' });
export type Context = Awaited<ReturnType<typeof createContext>>;
const t = initTRPC.context<Context>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
  if (!ctx.userId) throw new Error('Not authenticated');
  return next({ ctx });
});

oRpc

src/server/orpc/init.ts
import { os, ORPCError } from "@orpc/server";


export const publicProcedure = os

export const protectedProcedure = os.use( async ({ ctx, next }) => {
  if (!ctx.userId) throw new ORPCError("UNAUTHORIZED", { message: "Not authenticated" });
  const result = await next({ ctx });

  return result
});

export { ORPCError }

The measurement discipline

Every technique here is falsifiable. Apply one, then check the route table: did the page actually move from dynamic to static, or did you just add code? next build prints the answer per route, and it disagrees with intuition more often than you'd expect.

On this page