Skip to content

Maximizing Server Rendering for Interactive Next.js Applications

Maximizing Server Rendering for Interactive Next.js Applications

Maximizing Server Rendering for Interactive Next.js Applications

Next.js has come a long way since its inception, and it's now a powerful framework that combines both server-side and client-side rendering. With the introduction of React Server Components (RSCs) and the classic client components, you have the flexibility to create a purely client-rendered app with many client components or a purely server-rendered app using RSCs. However, what if you want to have the best of both worlds?

Server rendering improves performance and can deliver much of the HTML quickly, reducing the need for the client to render the entire page. This also results in smaller bundles delivered to the client. The question is, how would you maximize server rendering while still having an interactive application? This blog post will show you how.

Building a Lightweight Shopping Experience

To illustrate the concept, we'll build a lightweight shopping experience with a list of products and an "Add to Cart" button that opens a drawer with the added cart items. Most of the page will be server-rendered, and we'll use client components only to show the drawer. Even the drawer itself will not be entirely a client component; rather, the wrapper necessary to show the drawer will be a client component, while the contents will be server-rendered.

Cart design concept

This can be accomplished by the nature of client components that can accept server-rendered content. We'll set up a React context provider at the layout level, which will control showing or hiding the cart. This provider will be used by our client components (the "Add to Cart" button and the "Show Cart" button), as well as by the drawer wrapper.

When you click "Add to Cart," this will invoke a server action (more on that later), and then use a function provided through the context of the provider to show the drawer.

The Code

For this example, we'll use shadcn as our component library. It looks nice, and it also provides a good, accessible component for our cart drawer. Looking at our wireframe above, we'll need a total of three components:

The components can be installed with a single command:

npx shadcn-ui@latest button table sheet

Next, we'll set up our React context provider:

// app/layout.tsx
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <CartProvider>
        <body
          className={cn(
            "min-h-screen bg-background font-sans antialiased",
            fontSans.variable,
          )}
        >
          {children}
        </body>
      </CartProvider>
    </html>
  );
}
// app/components/cart-provider.tsx
"use client";

import { createContext, useState } from "react";

type CartProviderContextValue = {
  isCartShown: boolean;
  setCartShown: (isCartShown: boolean) => void;
};

export const CartContext = createContext<CartProviderContextValue>({
  isCartShown: false,
  setCartShown: () => {},
});

export interface CartProviderProps {
  children?: React.ReactNode;
}

export default function CartProvider({ children }: CartProviderProps) {
  const [isShown, setShown] = useState(false);

  return (
    <CartContext.Provider
      value={{
        isCartShown: isShown,
        setCartShown: setShown,
      }}
    >
      {children}
    </CartContext.Provider>
  );
}

The provider provides a boolean flag to indicate whether the cart is currently shown (isCartShown) and a function to change that (setCartShown). Note that CartProvider is a client component, but its contents are provided as children. Therefore, CartProvider is just a thin client-component wrapper around its contents.

Now, let's see the implementation of the "Add to Cart" button and the CartDrawer component. The AddToCartButton is a client component that reads the React context and calls its setCartShown function after adding to the cart has been completed. This will, in turn, set the isCartShown flag on the provider and show the cart drawer, which also reads the isCartShown flag from the provider. How is the cart updated, though? We'll use a server action named addToCartAction for that. We'll explain how this works in a bit, but in the meantime, let's see the implementations of the AddToCart button and the CartDrawer component.

// app/components/add-to-cart-button.tsx
"use client";

import { addToCartAction } from "@/app/actions/cart";
import { Button } from "@/app/components/ui/button";
import { useContext } from "react";
import { CartContext } from "@/app/components/cart-provider";

export type AddToCartButtonProps = {
  productId: number;
};

export default function AddToCartButton({ productId }: AddToCartButtonProps) {
  const { setCartShown } = useContext(CartContext);

  const handleAddToCart = async () => {
    await addToCartAction(productId);
    setCartShown(true);
  };

  return <Button onClick={handleAddToCart}>Add to Cart</Button>;
}

Similar to the CartProvider component, the' CartDrawer' component is a thin client component wrapper around its contents. It reads the React context provided by the CartProvider and passes it to the Sheet component from the Shadcn library. The SheetContent component accepts cart contents as children passed from the CartDrawer's parent, which is server-rendered.

// app/components/cart-drawer.tsx
"use client";

import {
  Sheet,
  SheetContent,
  SheetHeader,
  SheetTitle,
} from "@/app/components/ui/sheet";
import { useContext } from "react";
import { CartContext } from "@/app/components/cart-provider";

type CartDrawerProps = {
  children: React.ReactNode;
};

export default function CartDrawer({ children }: CartDrawerProps) {
  const { isCartShown, setCartShown } = useContext(CartContext);

  return (
    <Sheet open={isCartShown} onOpenChange={setCartShown}>
      <SheetContent>
        <SheetHeader>
          <SheetTitle>Cart</SheetTitle>
        </SheetHeader>
        {children}
      </SheetContent>
    </Sheet>
  );
}

Using Server Actions and Cache Invalidation

But how do we ensure that the contents are updated? The contents are an RSC component called CartContents that calls getCachedCart in the data access layer. In real-world scenarios, getCachedCart would probably call a database or an API, but we have neither here. So, we'll simulate a database through the use of the file-system-db package, a lightweight Node library that can write and read to a JSON file as a database.

The get calls to the database are wrapped in unstable_cache, which allows us to tag such requests with cart. This means CartContents is initially fetching data from the tagged getCachedCart call.

// app/data-access/cart.ts

export const getCachedCart = unstable_cache(
  async () => {
    return await getCart();
  },
  ["getCart"],
  { tags: ["cart"] },
);

export function getCart(): Promise<Cart> {
  return Promise.resolve(db.get("cart"));
}

Whenever we mutate the cart, we want to call invalidateTags("cart"). This will force purging of Next.js caches and consequently re-render the cart. For example, the "Add to Cart" button calls the addToCartAction server action, which adds the item to the database and calls invalidateTags("cart"). This server action call returns an updated RSC payload to the client, which re-renders the part that depended on the getCachedCart call - and that is the CartContents component.

// app/actions/cart.ts

export async function addToCartAction(productId: number) {
  await addToCart(productId);
  // Adding the following revalidates the data cache and makes this server action return updated RSC data, which the client part re-renders
  revalidateTag("cart");
}
// app/data-access/cart.ts
export async function addToCart(productId: number) {
  const product = PRODUCTS.find((product) => product.id === productId);
  if (product) {
    const cart = await getCart();
    cart.items.push(product);
    await db.set("cart", cart);
  }

  return Promise.resolve();
}

And that's it. We now have a fully functional, mostly server-rendered cart, where only small bits of interactivity are modeled as client components. Here is the result:

Animation showing cart in action

Some of the code shown above remains, such as the "Show Cart" button and the table of products, but those are just UX improvements on top of the core functionality. You can see the entire source code on Stackblitz.

Wrapping Up

In this blog post, we've explored how to maximize server rendering in Next.js while still having an interactive application. By leveraging React Server Components, client components, and server actions, we've created a shopping experience where most of the page is server-rendered, but the cart drawer is interactive and updates in real time. This approach improves performance, reduces the client-side rendering burden, and delivers smaller bundles to the client.

This Dot is a consultancy dedicated to guiding companies through their modernization and digital transformation journeys. Specializing in replatforming, modernizing, and launching new initiatives, we stand out by taking true ownership of your engineering projects.

We love helping teams with projects that have missed their deadlines or helping keep your strategic digital initiatives on course. Check out our case studies and our clients that trust us with their engineering.