Reduce static page size with smart Next.js components
At GRRR, we're delivering a system where the client can build pages with stacked blocks. Look at it like Legos. You can use them wherever you like. As developers we would call these blocks components
.
The output of the API is used to generate a static website with Next.js. Read more about why we choose to create static websites within this blogpost.
The data for each block is coming from the CMS. Like a title
and a bit of text
.
// Example data
{
blockName: "text",
title: "Volgens ons kan de wereld schoner, eerlijker en kleurrijker.",
text: "<p>Daarom combineren we de kracht van strategie, design en tech met Zinnige Zaken. Zo ruimen we ......"
},
There are also blocks where we need a bit more data. Like “Show the latest 3 blog posts from a given subject”.
// Example data
{
blockName: "latest-posts-from-subject",
tag: "technical"
},
The client can select a subject (like technical
), but you don’t want the client to update this block every time there’s a new article out on this subject. So we need some more data here. But how?
Possible Solutions
We could just fetch this data from the client. There’s an API, so we could do it. But we don’t want to. Most pages are information-dense pages for Marketing websites. Not platforms with always changing states. So it would be overkill to load all of this data client-side. And don’t forget that bots are still better at parsing simple static pages.
Another solution could be to make all of the data available for the entire app. We tried this. And it works, but there’s a huge downside. With a medium-large website, your HTML files will become multiple MB’s in size. So don’t try that at home!
A ServerSide solution
With all of those options out of the way, what’s left? The answer is the Server. We’re a big fan of statically generated websites, so technically it’s within a build step on the server.
Our solution to this problem is the prepare
function. This is a function where you can get additional data and provide this data to a component as its props. This is great for our issue in that the client can place any block wherever they like. Therefore, this prepare function is connected to the component.
This function is executed on the server, so there’s no need for client-side fetching or overloading a site with data.
Can you use it?
The basic implementation is quite simple, but there’s a requirement from the API’s output.
Because this code should run on the server, we want to execute this code somewhere within the pages
folder. For our statically generated sites, its location is within the getStaticProps
function.
Within this function, we’ve received an array with blocks from the API. Each block has a block name
so we can render the correct block for each item within this array.
const data = [
{
blockName: "text",
title: "Volgens ons kan de wereld schoner, eerlijker en kleurrijker.",
text: "<p>Daarom combineren we de kracht van strategie, design en tech met Zinnige Zaken. Zo ruimen we ......"
},
{
blockName: "latest-posts-from-subject",
tag: "technical"
},
... And so it goes on.
]
Each component in our codebase has the option to be connected to a prepare function. Within the getStaticProps
function, we’re looping over this array of blocks and checking if any of these corresponding components have a prepare function connected to them. If so, this function is executed. The output of this function is sent as props
to the component.
The component is later on rendered somewhere within JSX with that additional data.
Technical implementation
Let’s take a look at the implementation!
Each component has a folder with the files
interface.ts
-> The component’s TypeScript interfacestyles.module.scss
-> Scoped CSSindex.tsx
-> The React componentIf you want to have a prepare function for this component, the file
prepare.ts
is added to this list. The export statement of the component is also changed (you will see why later on)
// index.tsx -> A default component
export default function ExampleComponent({ title, tag }: ComponentInterface) {
return (
<section>
<h2>{title}</h2>
// articles aren't available without the prepare function.
<ul>
{articles.map((article) => (
<li key={article.id}>
<ArticlePreview article={article} />
</li>
))}
</ul>
</section>
);
}
to
// index.tsx -> A component with a prepare function
// Import the util
import withPrepare from "with-prepare";
// Import the matching prepare function
import prepare from "./prepare";
function ExampleComponent({
title,
tag,
articles,
}: PreparedComponentInterface) {
return (
<section>
<h2>{title}</h2>
<ul>
{articles.map((article) => (
<li key={article.id}>
<ArticlePreview article={article} />
</li>
))}
</ul>
</section>
);
}
// Export the component like this
export default withPrepare<ComponentInterface, PreparedComponentInterface>(
prepare
)(ExampleComponent);
The withPrepare
util function will return an object with the shape:
{
component: Component,
prepare: prepare
}
This creates the possibility to execute the correct prepare
function within the getStaticProps
function and also render the correct Component
later on within the JSX.
A prepare function would look something like this:
// prepare.ts
import type ComponentInterface from "./interface";
import type { PreparedComponentInterface } from "./interface";
import type { Page } from "../../../content/pages-interfaces";
import type { JsonApiObject } from "../../../content/api-interfaces";
// Function that's executed within the getStaticProps function
export default function prepare(
// The initial props from the component (like the title)
initialProps: ComponentInterface,
// The data from the page it's on (we need this sometimes)
pageData: Page
): PreparedComponentInterface {
// Get the latest articles from the tag, no need to dive into this code here.
const articles = getArticlesFromTag(initialProps.tag);
return {
// Spread the initialProps so they stay available
...props,
// Add the articles to the props of the component
articles,
};
}
And within the getStaticProps
function, we will execute every prepare
function for the page data:
// getStaticProps.ts
export const getStaticProps: GetStaticProps = async (context) => {
// Get the data from this specific page
const pageData = getPageData(context);
// Enhance the page data
const fullPageData = await createFullPageData(page);
// Return the enhanced page data to the page
return {
props: {
pageData: fullPageData,
},
};
};
Where the createFullPageData
function will do something like this
// createFullPageData.ts (a simplified version of it)
// The function is async so we can make some API calls here
export default async function createFullPageData(page: Page) {
const content = await Promise.all(
page.data.map(async (componentProps) => {
// Get the component from a setup list with items like (text: Text)
const Component = blockToComponent[componentProps.blockName];
if ("prepare" in Component) {
// Execute the prepare function
const propsAfterPrepare = await Component.prepare(
componentProps,
pageData
);
// Return the enhanced props
return propsAfterPrepare;
}
// Return the initial props if there isn't a prepare function for this component
return componentProps;
})
);
return content;
}
Now, the data for each component within the page-data is enhanced with the data from the prepare
function. When those components are rendered somewhere in the JSX, the props are the output of the prepare
functions.
Reacts Server Components
With Next.js 13 and React 18 there’s a new thing called Server Components. This is another solution to the problem described in this article. The sweet thing about Server Components is the mental model. it’s simpler than the mental modal of our prepare function. Therefore, we’re looking into this right now. It could be a nice replacement for our solution.
If we’re done looking, we will come back with a blog post about it. We’re curious if this will replace our prepare function or if there’s still a use case for it.