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.
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??
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.
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.
| Profile | Use Case | stale | revalidate | expire |
|---|---|---|---|---|
default | Standard content | 5 minutes | 15 minutes | 1 year |
seconds | Real-time data | 30 seconds | 1 second | 1 minute |
minutes | Frequently updated content | 5 minutes | 1 minute | |
hours | Content updated multiple times per day | 5 minutes | 1 hour | 1 day |
days | Content updated daily | 5 minutes | 1 day | |
weeks | Content updated weekly | 5 minutes | 1 week | 30 days |
max | Stable content that rarely changes | 5 minutes | 30 days | 1 year |
You can create custom profiles as well if none of the predefined ones fit your needs.
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
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 neededThat app type export is useful cause it allows you to use a kind of rpc approach in your Next.js app like this:
Hono
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 neededAs 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
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
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.