The Bulletin

Thursday, February 20, 2025

A Top-Down Approach to Next.JS

A light and comprehensive introduction to Next.JS, made for curious React developers.

Sean Fong

Sean Fong

@seancfong

Next.JS as a meta-framework

Next.JS is one of the most popular JavaScript frameworks that enables rapid development iterations and cutting-edge performance. It is a meta-framework built on top of React, providing capabilities of server-side rendering and caching, effectively becoming a fantastic tool for building scalable full-stack applications.

Loosely speaking, Next.JS is just an “all batteries included” framework that combines together the fundamentals React and Express.

React as a Library

Existentially, React by itself is a library that stores and manipulates the contents of a page/app in a lightweight and abstract form (the virtual DOM).

This is how, for example, React can be used for websites (React DOM) or for mobile apps (React Native), even though React Native doesn’t directly render DOM elements. Essentially, for single-page applications, a user will receive an empty HTML file, and then React takes over to build the entire page using JavaScript.

Next.JS uses React as a front-end to build web interfaces using JSX.

The Core of Express.JS

Next.JS doesn’t inherently use Express, but it provides the ability to write secure server-side code for operations such as building a REST API. You obviously never want to store sensitive data or handle doing it on the front-end, and this is one of the reasons why Next.JS is adamant on “server-first” code.

1. The Server-first Paradigm

Let’s think backwards. By default, all React code in a Next.JS project will run on the server. This means that essentially, Next.JS will try its best to statically pre-render every page on the server as one giant HTML file. (This is why you need to run npm build before deploying). No need for React to render everything out using JavaScript during runtime, reaping huge performance and SEO benefits.

page.tsx

lang:tsx

export default function Page() {
return (
<div>
<h1>Blog Post</h1>
<p>This is rendered on the server</p>
</div>
)
}
// Turns into the following, after build step:
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<div>
<h1>Blog Post</h1>
<p>This is rendered on the server</p>
</div>
</body>
</html>
app

This approach alone will only work for static pages (like this example), but in reality this isn't always the case:

Handling Interactivity

Whenever a user interacts with the page, JavaScript will still be needed to handle state.

This fix is pretty easy. To use React’s hooks, Next.JS requires you to separate client-side interactive code within client components. However, client components must be declared in a separate file with the "use client" decorator at the very top line.

page.tsx

lang:tsx

export default function Page() {
return (
<div>
<h1>Blog Post</h1>
{/* Client Component */}
<PageContent />
{/* Client Component */}
<LikesCounter />
</div>
)
}
// Turns into the following, after build step:
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<div>
<h1>Blog Post</h1>
{/* <PageContent /> */}
<p></p>
{/* <LikesCounter /> */}
<div>
<p>Likes</p>
<button>0</button>
</div>
</div>
</body>
<script>
...
</script>
</html>
app
components

Even though the LikesCounter component is a client-component, Next.JS will still smartly optimize the static content to be included in the pre-rendered HTML. The page will also have to come with the React runtime bundled with it to handle user interactions (within the <script> tag).

Forget fetching data with useEffect

Here is how a single-page application fetches data:

  • Empty HTML file gets loaded to the client
  • Client downloads JS bundle and loads it
  • React boots up and builds the virtual DOM
  • Components mount, firing the useEffect to fetch the data
  • React sets the state and renders it

On the initial page load, in Next.JS we prefer to not fetch dynamic data using useEffect (as done in <PageContent />). There are better ways of doing this, since it’s more efficient when fetches are done on the server instead of with React on the client.

This is where things start getting weird and seem to go against the architecture of React.

To securely fetch data on the server, server components can also be declared async to use the async-await syntax.

page.tsx

lang:tsx

export default async function Page() {
// Everything here will be done on the server and not on the client,
// which means we can use API keys, sensitive data, check sessions, etc.
// All of this code gets executed exactly once on page load.
const response = await fetch(...);
const data = await response.json();
return (
<div>
<h1>Blog Post</h1>
{/* Server Component, since no hooks used here. */}
<PageContent data={data}/>
{/* Client Component */}
<LikesCounter />
</div>
)
}
app
components

We’re so used to wrapping fetches in useEffect, since that guarantees it only runs once on the client (React re-renders all affected components and children when their state changes. Wrapping fetches in useEffects avoids unnecessary fetching on every re-render).

But in server components, everything gets run once because there is no need for the server to handle state and maintain the React lifecycle at all (in fact, all servers should be stateless for security).

Note: Next.JS will still by default call the fetch during build time and attempt to pre-render everything as a static page. If the fetch is a hard-coded endpoint, the HTML page content will directly include the response from that endpoint. This route is a static route, unless opted out of.

Dynamic Data Fetching with Route Params

What should we do if we have multiple blog posts and want to re-use page.tsx as a template for each one? So far, this code just works if we hard-code some value for the fetch URL.

This is where file-based routing is useful. We can create a directory called post in the app directory to render this page whenever the user visits /post on the website URL.

Additionally, if we want a specific post to fetch, we can use route params as the file name. This is done with surrounding the name of the param with brackets, such as /post/[slug] , where slug is the identifier of a post. This opts /post/[slug]/page.tsx into a dynamic route, meaning that the HTML will render during request time.

page.tsx

lang:tsx

export default async function Page({ params }: { params: { slug: string }}) {
// This page builds on the server during request-time
const { slug } = params;
const response = await getPostBySlug(slug);
const data = await response.json();
return (
<div>
<h1>Blog Post</h1>
<PageContent data={data} />
<LikesCounter />
</div>
)
}
app
post
[slug]

Encapsulate blocking logic as much as possible

However, we just made a huge bottleneck on the load performance of the application, since in order for anything to load, we must first await the fetch to complete in the page component. We can fix this by converting <PageContent/> into a server component instead, encapsulating the blocking logic and allowing everything else to load.

page.tsx

lang:tsx

export default async function Page({params}: { params: { slug: string }}) {
// This page builds on the server during **request-time**
const { slug } = params;
return (
<div>
<h1>Blog Post</h1>
<PageContent slug={slug} />
<LikesCounter />
</div>
)
}
app
post
[slug]
components

Now, when the page is loaded, the title and the 0 likes will load first, then the post content will be magically streamed into the page when the fetch in <PostContent /> completes.

2. Client-Server Composition

So now we understand that by default, Next.JS components are server components, and client components need to be explicitly decorated with "use client" .

Server and Client component composition follow two basic rules:

  1. Server components can only render client components
  2. Client components can not render server components, with two exceptions:
    1. Except for context providers or any client component accepting server components as a children prop
    2. The server component doesn’t perform an asynchronous action such as fetching data. This nested component will be inferred as a client or a server component automatically by Next.JS depending on its ancestors.

As such, if <ClientComponent /> renders a <NestedComponent />, there is no need to decorate <NestedComponent /> with "use client". Next.JS will do it automatically for multiply-nested client components. This also provides performance flexibility, since it will be a server component if <ServerComponent/> renders <NestedComponent/>.

3. Mutating Data

Now that we can fetch data on the server, many apps need a way of changing it. A good example is a to-do app.

According to what we know, here is what a basic app looks like:

page.tsx

lang:tsx

export default async function Page() {
// Assuming that getCurrentSession reads cookies, this will also opt in the
// route to be dynamic, similar to route params.
const { user } = await getCurrentSession();
return (
<div>
<h1>Todo list of {user.name}</h1>
<TodoList userId={user.id} />
</div>
)
}
app
components

Naively, Next.JS allows you to write server route handlers within the project. This is similar to Express where HTTP methods are exported as verbs.

/api/todos/route.ts

lang:ts

export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const idToDelete = (await params).id;
const { user } = await getCurrentSession();
await db.delete
.from(todos)
.where(
and(
eq(todos.userId, user.id),
eq(todos.id, idToDelete)
)
);
return Response.json(...);
}

This works, but Next.JS has a cool trick up its sleeve:

Server Actions and their benefits

First of all, route handlers don’t natively implement CSRF protection, meaning a malicious actor can trick you into doing unauthorized actions if they know your website’s url and endpoints, even if you aren’t on that website.

Server actions, which are now the idiomatic standard of running mutations in Next.JS, are now preferred. It’s simply a JavaScript function that gets executed on the server, except client components can “invoke” it.

This provides a couple of benefits:

  1. Secure by default - Server actions are just POST requests under the hood with built-in CSRF protection.
  2. Type-safe - Instead of passing a string to fetch, server actions are just functions with arguments as their payload.
  3. Convenient with high-level of control - Server actions can directly trigger the page to update its data without having to refresh it.

Refreshing data can be done with revalidatePath (or revalidateTag if fetched with a tag for caching). There’s no need to handle updating the change on the client anymore.

This creates a very clean push-pull relationship between the client and the server. No more multiple requests and having to figure out how to manually update things. Declarative programming patterns just win in this example.

Server actions are decorated with "use server" , and they can either be:

  1. Inlined within a client component
  2. Imported from a separate file

In this example, we'll refactor the delete logic to be an inlined server action within list-item.tsx.

page.tsx

lang:tsx

export default async function Page() {
const { user } = await getCurrentSession();
return (
<div>
<h1>Todo list of {user.name}</h1>
<TodoList userId={user.id} />
</div>
)
}
app
components

It’s another weird quirk about Next.JS, but it really exemplifies the theme of trying to “blend” together the logic between client and server for added convenience.

During build time, Next.JS will separate the server action from the client component so the client-side code can only invoke it without knowing its internals.

Note: Server actions can also return values like regular functions, which can be helpful for error states, generating id values, etc.

4. Additional Considerations

Be wary of client-side stores (Redux, Zustand)

Client-side stores are very popular for managing complex client-side state for single-page applications, and they can also be shared across routes since everything is done on the client.

Because Next.JS follows the server-first paradigm, be cautious of using stores, and ensure they only populate on the client. Any shared stores on the server between requests is a detrimental security concern.

Improve blocking server-side code with parallel fetches and mutations

Ideally, one-shotting queries are better than multiple queries that execute in a row. However, there may be times when mutually independent queries are executed in sequence instead of parallel.

Like the example above, it’s better to encapsulate independent fetches within server components. However, a parent component may have to pass the same data to multiple children.

blocking-fetch.tsx

lang:tsx

export default async function ServerComponent({ userId }: { userId: number }) {
const itemsA = await getTypeA(userId);
const itemsB = await getTypeB(userId);
...
// None of the queries depend on any of their previous ones!
// This is a highly-blocking component which can yield poor load times
return (
<>
<X dataA={itemsA} dataB={itemsB} />
<Y dataA={itemsA} dataB={itemsB} />
</>
);
}

improved-fetch.tsx

lang:tsx

// Parallel fetching
export default async function BetterServerComponent({ userId }: { userId: number }) {
const [itemsA, itemsB, ...] = await Promise.all([
getTypeA(userId),
getTypeB(userId),
...
]);
return (
<>
<X dataA={itemsA} dataB={itemsB} />
<Y dataA={itemsA} dataB={itemsB} />
</>
);
}

A parallel fetch in a parent component can re-use the fetched data across multiple children components and improve query efficiency.

React Suspense and Streaming

We can take this further by adding a loading UI to asynchronous components while they perform their fetching. For example, if we want to show a loading indicator when <BetterServerComponent/> is blocked fetching data:

suspense-page.tsx

lang:tsx

// Parallel fetching
export default function Page() {
const { user } = await getCurrentSession();
return (
<Suspense fallback={<LoadingSpinner />}>
{/* The below component is "blocking" because it is an async component */}
<BetterServerComponent userId={user.id} />
</Suspense>
);
}