We recently redesigned the Medusa Next.js Starter Template. The redesign also included a larger refactor to use Next.js 14 and the newer Next/React features like App Router, Server Components, and Server Actions. If you are considering migrating to Next.js 14 and Server Components here’s everything we learned.
The challenge
Medusa is an open-source backend commerce framework with APIs and tools to manage Products, Orders, Customers, and more. For everything beyond standard commerce functionality, we make it easy to introduce your own custom data models, business logic, API endpoints, etc. Medusa’s APIs can be used in storefronts that your customers interact with - an example of such a storefront is our Next.js starter.
On many websites, content changes relatively infrequently. And when it changes it’s often acceptable that there is some delay between content updates to visitors seeing it. For a blog post, for example, it’s acceptable that a few minutes pass between hitting publish and the post showing up on the site. When this is the case, statically generating and serving the webpages via a CDN greatly benefits visitors as the page loads quickly.
In e-commerce, however, dynamism is crucial. When a customer adds something to their cart, it should happen instantly. If a product sells out, it should be immediately clear to all customers. And with multi-currency and personalized prices, the customer should see the price they will be charged in real time. E-commerce websites, therefore, have to interact with a server that can deliver this information. Front-end frameworks like React all have mechanisms for this and in the past years, the common approach has been directing browsers to fetch data from servers that the framework would then render to HTML on the client.
This is also what Medusa’s Next.js starter did. When the customer visits the Cart page the starter would trigger an API call from the browser to fetch the customer’s cart data from Medusa and render it. To keep track of the user’s Cart and optimize the storefront experience, the previous version used client-side state and React Context to store Cart IDs, cache requests, and much more.
With Next.js 14, you can still make requests from the client; however, new tools like React Server Components and Server Actions are now available to simplify the developer experience for caching and state management when building highly dynamic websites. This new paradigm requires you to think differently about how you build your React applications, so there was a bit of a learning curve to migrating the starter to using the new tools. Below are the key differences I encountered and how I have understood React’s new tools.
Data fetching and state
Let’s tackle data fetching and state management first, taking the shopping cart as an example.
The Medusa server already keeps track of the contents of the current shopping cart. By retrieving this data, storing it in the Next cache, and then accessing that cached data from any component that might need it, we eliminate the need to manage the cart state in the client.
By revalidating the cart data whenever we modify it, we make sure the cache always holds an updated version of the cart, accessible throughout the app.
Let’s dig a bit deeper into how this works in Next.js:
Fetching and caching
Next.js has a caching mechanism built-in that automatically works with the native fetch
API. This means that every fetch request is automatically cached until revalidated. As a result, you can safely fetch the same data from every component that needs it without worrying about doing 20 duplicate requests. This eliminates the need for keeping a global data state using Context or prop drilling.
At first, re-fetching the same data multiple times in a component tree seemed counterintuitive as you’d normally fetch data in one place, and pass it down via either Context or props. You must also remember to revalidate the relevant parts of the cache every time you mutate data on the server. These patterns take some getting used to.
My way of thinking about it is that in the previous model, a fetch
meant going to the server and getting data back. With Next.js’s caching mechanism, a fetch
is an abstraction for an intent to get some data and Next.js is then in charge of delivering it either from its cache or by going to the server for you. This creates super fast interactions, but it also forces you to be meticulous about revalidation which was not a concern previously.
Once figured out, the caching mechanics are very effective in practice. Fetching new data from the server is swift, and all subsequent requests load almost instantly from the cache. This leads to a responsive front-end performance when navigating through your app.
Data mutations with Server Actions
Now we know how to fetch and cache data. But how do we handle data mutations, like adding an item to the shopping cart? User interactions often trigger these mutations and require us to use a Client Component to render variant selectors and an "add to cart" button. But we aim to manage API interactions server-side. To accomplish this from a Client Component, we can use Server Actions.
Server Actions are asynchronous functions executed on the server. They can manage form submissions and data mutations in Client Components. Under the hood they trigger a POST request to the Next.js server. The actions can be used like any other function in your project, making them convenient to work with.
In the new Starter, we use the Medusa JS client through Server Actions to interact with the Medusa server. This replaces many client-side data mutation hooks from the medusa-react
package used in previous versions.
Implementing “add to cart” - before and after
Let’s take the “add to cart” action and compare how to implement it in both the old and the new version.
First of all, we no longer have to wrap our root layout in Context Providers to give its children access to data and actions. In the new setup, we can keep the root layout simple and server-rendered.
Instead of defining the addToCart
function in the ProductProvider
, we’d now export it from an actions.ts
file. There’s two significant differences here you should note:
- The
“use server”
keyword at top of the file. This tells Next.js to execute this function on the server, even if its called from a client component. - The
revalidateTag
function call on line 15. This ensures that cart cache is revalidated and all components using cart data will fetch the latest data: the cart with the newly added item in it.
Now we can import and use the addToCart
function like any other function in a client component. Notice the "use client"
keyword on top of the file. This tells Next.js this should be a client component. The product variant is fetched in its parent Server Component, and passed down as a prop. All data fetching and mutating is handled by the server.
Server vs Client components
React Server Components allow you to build UIs that can be rendered on the server. Server components have a couple of benefits. Most notably, they have access to server-side data fetching. This can enhance performance by decreasing data fetching time and reducing the number of client requests. It also means you don’t have to expose API keys in the browser or worry about CORS. They can also be pre-rendered or cached on the server. This means the client gets served the rendered HTML and doesn’t need to download, parse and execute the JavaScript needed to render the page.
So when should you use a Server Component? As a rule of thumb, any component can be a Server Component, unless it needs client-side interactivity. If you need access to React hooks like useState
or useEffect
, or want to attach an onClick
event handler to a button, you need to use a Client Component.
Client Components can be children of Server Components, meaning you can use a Server Component to fetch data server-side, and pass it down as a prop to the Client Component.
Let’s have a look at the product page to see the component structure:
All components marked in red, including the page itself, are Server Components. There are two exceptions: the product actions and the cart button/modal on the top right. These require client-side interactivity and event handlers. Since they are both rendered within Server Components, we can fetch cart and product data on the server and pass it down as a prop.
Static pre-rendering
Looking at the product page above, we can identify some parts of the page that use static data. The product title, description and images are all available when we build the app, and don’t change. They won’t change based on user behaviour and the general change frequency isn’t high. This means we can pre-render these parts of the page at build time as static HTML. This will improve page loading times.
The cool thing is that we can leave ‘holes’ in the static HTML for the server to stream dynamic data into. The server can quickly deliver the statically generated HTML with fallbacks. This is done by wrapping the components that require dynamic data within <Suspense>
boundaries and providing a fallback. As the user views the page, the server streams in the dynamic data, replacing the static fallbacks.
Moving state to the URL
In the process of migrating to Next.js 14, one key shift in approach is the idea of moving state to the URL. This concept involves manipulating the URL to reflect and manage the application's state, providing a global state when using Server Side Rendering. Let's delve into how and why we implemented this strategy.
Medusa offers support for multi-region stores, allowing variation in pricing, inventory, and fulfillment options based on the user's region. The current region should be accessible by multiple components in the tree, on both the server and client sides. To facilitate this, we added a country code to the path, moving the region state to the URL. E.g. https://next.medusajs.com/gb or https://next.medusajs.com/nl.
There’s a couple of reasons we decided move the country code to the path:
- It enables static pre-rendering.
- It makes the country code globally available.
- It allows search engines to index localized pages. This has a massive SEO benefit if you run a multi-region / multi-language store and want to rank well in all regions.
There’s a reason we didn’t go with URL search parameters to keep track of the region. I’ll explain why later.
Using search params
Another example of where we moved state to the URL is in the checkout flow. Since it's a one-page checkout, we must monitor the completed checkout steps and determine which parts of the checkout UI should be rendered.
In the previous version, we’d keep track of this checkout state using React Context. Since the high-level checkout components have been upgraded to Server Components and the checkout state needs to be accessible to all child components, we've relocated the state to a step=
search parameter in the URL.
There’s a couple of caveats to moving state to the URL. First of all, accessing search params from Server Components is a hassle. On the server side, they’re only available from within page.tsx
, where they will be passed as props. If you need the params in any other Server Component, you’ll need to pass it down as a prop from the main page.
From Client Components it’s easy to access the dynamic URL params with the useParams
and useSearchParams
React Hooks respectively.
Be aware that using the searchParams
prop or the useSearchParams
disables static rendering. As a result, the entire route will be dynamically rendered. If you only need to access the params from a Client Component, consider wrapping that component in <Suspense>
boundaries. This allows dynamic rendering up to that Suspense boundary, while the rest of the page can still be statically rendered.
To the best of my understanding, this doesn't apply to params
and useParams
. That's why I decided to use them for the country code. As a dynamic path parameter it’s still globally accessible, but doesn’t disable static rendering.
Conclusion
In conclusion, the new patterns for fetching/caching that Next.js 14 introduces require some getting used to. Once you get the hang of it, It’s quite convenient to just be able to fetch the data where you need it, instead of having to set up client-side Context or endless prop drilling. The caching makes your UI feel very snappy and fun to use.
Same goes for working with Server Components and Server Actions. It requires you to plan out the structure of your app and forces you to think about what can be rendered on the server, and what has to be rendered in the client.
In my opinion, this method of work contributes to a cleaner, well-structured codebase. It clearly separates server and client code, logically groups Server Actions together, and the App Router enforces a clear project structure.
However, the approach is opinionated and involves a lot of 'magic' happening behind the scenes, such as with caching and Server Actions. Whether or not you're comfortable with this ultimately depends on personal preference.
Want to try out the Medusa Next.js Starter?
Like Medusa, the Next.js Starter is fully open source. Check out the live demo or get the code on GitHub.