JAMstack Origins: A deep dive into the architecture with Next.js
The evolution of frontend JS frameworks has drastically changed the ways users see and interact with a website. Numerous libraries and frameworks have taken birth to push the boundaries of what a website is capable of. Our focus for today is going to be on React which has been the library of choice for a multitude of our projects.
Server-Side Rendered Apps
This is the age-old way of rendering websites. This comprises a server that renders fully-compiled, ready to display HTML to the browser. The entire flow can be outlined with the below steps like so:
- A user requests a website on a browser by entering a URL (Eg: www.cantthinkofaname.com).
- The server we just spoke of verifies that the request is in order, compiles the HTML and sends it over to the client.
- The browser receives this ready to display static page and renders it to the user immediately. However, the website is not interactive at this point.
- The client (browser in this case), then also downloads the JS to enable and allow interactivity upon execution.
Advantages of Server-Side Rendering
- Faster renders courtesy of the readily available HTML from the server.
- Ensures proper SEO (Search Engine Optimization) and webpages are indexed correctly.
- Better performance for smaller sites.
- Better performance on slow network devices.
Though this looks like a convenient way to get websites to render super quickly, unfortunately, it’s an ideal solution only if the website looks something like this site from the 90s :
- This was a time when websites mostly only contained images and text, which is no longer the case. Websites today involve messaging, videos, heavy user interaction all of which mean that the HTML to be generated by the server will be huge, necessitating the server to spend a large time compiling the page resulting in a slow TTFB (Time To First Byte).
- A busy server also means that it can serve fewer requests.
In order to alleviate the problems of a pure server-side rendered app, the solution of client-side rendering became hugely popular especially with the advent of client-side frameworks like Angular, React, etc. The explanations and code examples mentioned in the article references React.
- A user requests a website on a browser by entering a URL (Eg: www.cantthinkofaname.com)
This method has some significant advantages over a purely server-rendered app. The client is responsible for compiling the page resulting in huge performance gains post initial load as nothing needs to be downloaded from the server again.
Navigation is also noticeably faster courtesy of client-side routing. All of this translates to a lower server load thereby freeing it up to serve more requests.
The convenience of developing CSR apps as well as the smooth user experience and performance benefits it offered resulted in its massive adoption through numerous frameworks as this seemed like the ideal choice for applications that had a fair share of user interactivity. LiftOff was no exception to this trend as React was our library of choice for the numerous projects that we built over the last few years.
In spite of the seemingly huge gains that CSR offers, unfortunately, it is not without tradeoffs. As the browser does all the hard work, this would mean that the initial load can be pretty slow depending on the size and kind of the application. The JS bundle would increase in size as the codebase grows, necessitating aggressive code splitting and lazy-loading to ensure performance.
The performance can also turn out to be unpredictable depending on the capability of the user’s device and the speed of the network over which the website is being requested.
There also needs to be additional considerations when it comes to SEO. Though web crawlers can now crawl JS, there are still limitations and a CSR app involves additional work such as the use of pre-rendering solutions to make it work. Client-side routing can also result in delayed crawling.
A Hybrid Approach
After examining both these approaches, it is clear that both of them leave a lot to be desired. What if there were a way to have the best of both worlds, SSR’s super-fast initial loads, impeccable SEO capabilities coupled with smooth, experience and feel of a CSR.
In the case of a JAMstack application, the entire frontend is static HTML pages during the build process. These files are then served directly from a CDN, thereby massively increasing load speeds while reducing complexity.
JAMstack aims at decoupling frontend code from the server code. The server exposes GraphQL or REST endpoints which are used to statically generate the entire app during the build process.
Since JAMstack apps pre-render the whole site at build time, any change that needs to be reflected requires the app to be rebuilt. One example of this is probably a library management system that showcases all the books a library has. Every time a new book is added, the app needs to be rebuilt to reflect this.
This can be tackled in 2 ways:
- Saving the new book through an API and subsequently triggering a rebuild upon every new addition. This however is only feasible on a very small scale. A huge application with lots of new books added frequently would be hard-pressed to make such a system work.
- Render the books client-side and prevent them from getting pre-built. The books will always be fetched upon request and rendered entirely client-side. But this would mean the very goal of pre-rendering is defeated.
We shall see how Next.js solves this problem in a later section.
Static Site Generation with NextJS
Static site generators are typically the main contributors to a JAMstack site. Next.js, Gatsby, Nuxt are examples of such generators. These use markdown from APIs at build time, render the markup and host the static files on a CDN.
Next.js is a React based framework which supports Static Site Generation (SSG) as well as SSR.
It goes well with JAMstack as it allows us to dictate how each page in the app is rendered. Next.js also solves the limitation of expensive rebuilds where changes have to reflect immediately.
React Query is an open source data-fetching library for React which also takes care of caching, synchronizing and updating server state.
Forms of pre-rendering
In Next.js, the main building block is the page. A page is a React Component exported from the
pagesdirectory and each page is associated with a route based on its file name, e.g. the
pages/help.js corresponds to the
Next.js supports two forms here: Static Generation and Server-side Rendering. The difference is when it generates the HTML for a page.
- Static generation: The HTML is generated at build time and will be reused on each request. The page needs to export a function named
getStaticPropswhere the data required to render the page is fetched.
- Server-side generation: The HTML is generated on each request. The page needs to export a function named
getServerSidePropswhere the data required to render the page is fetched.
Next.js lets us choose the form we like for each page. There is also room to create a hybrid app by using Static generation for the majority of the pages and Server-side Rendering for the ones that need it.
Static Generation is the preferred way owing to performance reasons. Statically generated pages can be cached by CDN with no extra configuration to boost performance.
CSR can also be used along with SSG or SSR meaning that parts of a page can be rendered entirely client-side.
Incremental Static Regeneration
Incremental Static Regeneration allows us to update existing pages by re-rendering them in the background as traffic comes in. This can be enabled on a per-page basis. When the first request is made, the currently generated version is served, while in the background, the page is regenerated once every time the set interval passes. This re-generated page is served for subsequent requests. The interval is set in the
getStaticProps function of the page.
Let us build a simple site that displays a list of Star Wars Characters using Next.js
The site would have two pages (routes):
/charactersStatically generated list of all characters, along with incremental static re-generation to re-generate the page every 5 minutes.
/characters/[id]page which displays a single character in more detail. Also statically generated.
Let us set the
/ route to redirect to
We start by creating a new Next.js app:
mkdir star-wars && cd star-wars && npx create-next-app .
getStaticProps function handles data fetching and the incremental re-generation configuration with the revalidate key in the returned object. By setting it to 5 minutes, we ensure that any new characters will reflect at around 5 minutes time.
The corresponding component:
The service below is responsible for fetching the list of characters on the client. The
useCharacters hook makes use of the
useQuery hook provided by React Query to cache all the characters under a unique key
characters. It also caches each individual character under a compound key so that when
useCharacterDetail data would already be ready to be rendered while the actual API call happens in the background. A stale time is specified after which the cache is purged and the data is re-fetched. The
client is a function that makes the call using axios.
The data from the
getStaticProps method is passed as a prop to the page which is then passed as
initialData to React Query which bypasses the first fetch and caches this instead as data is already present.
Though this step can be avoided altogether by just using the
characters returned by the
useQuery acts as a fallback on the client-side in case there was an error fetching the data during build time. The prebuilt data is rendered and the client-side initial fetch is skipped if
getStaticProps successfully returns the list of characters and the same is provided through the
initialData property. If not, the same list is fetched client-side so that the user can still interact with the site as intended. It also helps maintain consistency of usage with other client-side calls.
As the open-source API does not provide a unique
id field for each of the characters, we are leveraging a makeshift id using the
index property. This is not ideal and should not be done in the real world.
Basic styling is handled via Emotion css props and styled components.
Avatar, List, etc are styled components written to add some minimal flair to the components. We shall skip the details of these as this is outside the scope of our topic.
Individual Character Page
This would have two data fetching functions as the route is dynamic:
getStaticPaths: Responsible for fetching all characters' ids to generate the corresponding pages.
getStaticProps: Responsible for fetching the data for a given character. This function will run once for every character
idreturned by the
fallback is set to true so that if a request was made to a character that wasn't available during the build, it can be incrementally re-generated.
The corresponding component:
A barebones demo with minimal styling based on this code can be found here. It can be noted that along with the page with the list of characters, every single dynamic route for a particular character is also pre-built resulting in near-instantaneous loads.
Here’s a lighthouse performance run for the same:
Let us replace
getServerSideProps and see how the app performs under Server-side Rendering. The demo for this can be found here.
Here’s the lighthouse performance run for the SSR version:
Although the score is still 100 owing to the simplicity of the app, it is clear that there’s room for improvement on the SSR version. The initial server response time was quite large, and the first two frames are empty when we look at the trace. There is also a significant difference in FCP times evident from the trace graphs for both.
Let us remove
getServerSideProps methods altogether and fetch the entire data client-side and render it. We shall display a loader in the interim.
A working demo of this can be found here.
We again see similar lighthouse results to the SSR
Despite blazing-fast loads, it can be observed that it isn’t quite as quick as a statically generated site as there is no pre-built HTML to render. The browser has to fetch the data, execute JS and compile the HTML before rendering it. This contributes to a significant dip in performance as the size and complexity of the app grows.
Advantages of JAMstack
- Blazing fast load times: JAMstack sites remove the need to generate page views on a server at request time by instead generating pages ahead of time during a build. With all the pages are already available on a CDN close to the user and ready to serve, very high performance is possible without introducing expensive or complex infrastructure.
- Scale: Where traditional apps add logic to cache commonly visited views, JAMstack provides this by default. With JAMstack sites, everything can be cached in a content delivery network. With simpler deployments, built-in redundancy, and incredible load capacity
- Stability: With fewer moving parts between the site and the content, there are fewer points of failure.
- Portability: JAMstack sites are pre-generated. That means that we can host them from an array of hosting providers and have a greater ability to move them to your preferred host. Any simple static hosting solution should be able to serve a JAMstack site. Few examples of such providers include Vercel, Netlify, etc.
All the code examples above have been hosted on Vercel, as have a number of projects at LiftOff recently. A Next.js app deployed on Vercel is automatically served from a CDN where all assets are automatically cached, except server-side rendered pages. Vercel also automagically deploys serverless functions written in the
/pages/api directory of our app. The region of deployment can be configured via the
vercel.json configuration file.
Jamstack apps are statically generated. By default, commits to the Git repository of the app will trigger a build with Vercel, creating a preview deployment that is hosted on a unique URL. Every single commit on every branch results in a preview deployment of the app! This not only helps with smooth management of multiple builds but also facilitates easy A/B Testing, as we have access to multiple concurrent deployments.
While SSR and CSR each have their own hits and misses, they can still be the ideal choice depending on the kind of application to be built. Jamstack, although performant and offers a superior development experience, can be a pain when the app is highly interactive and demands real-time reflection.
Next.js offers a best of both worlds model that allows the developer to choose between server-side and static generation depending upon the need of any given page. This can always be combined with a client-side rendering for parts of the application, thereby offering an ideal solution for a majority of the apps.
Combined with React Query, this further sweetens the pot, making data fetching, mutations, client-side cache handling dead simple.