home

/

writing

/

next.js-app-server-improvements

15 May 24

what are we talking about today?

I wanted to demo some code changes I made today, that saw some significant improvements on the Trinity Website. I changed how I do data fetching for blog posts and filtering of the data based on tags.
 

what’s the gist?

Well, my previous solution that did data fetching inside a rsc with url slugs was slow. So I moved it over to doing the data fetch in the page.tsx and passed the data as props and saw improvements.
First version data fetching in rsc.
Second version data fetching on the server.

what was wrong?

In the prior case I was calling my getStoryData() inside of rsc , which even as writing this is confusing to me as to why this would make any difference in terms of speed.
I was convinced I had marked the file “use client”; or was calling fetchData() inside of useEffect()
But I was not, I simply had an rsc that took in filters via a url slug and did a data fetch inside that rsc. I am under the assumption that the way I was passing url params into a rsc was causing some kind of re-render that acted as a client query. I am not certain!? Anyways, what that resulted in was a slow call that wasn’t fully utilising Next.js caching and had to be refetched for every tag click.
CODE EXAMPLES
/app/stories/[filter]/page.tsx
/components/stories/storyArea.tsx
 

what’s the point?

I only call getStoryData() once inside of page.tsx now, and every subsequent call that filters data based on tags uses the initial cached server data and does not need to do extra data fetching.
 
I did also add some prefetch={true} & priority={true} on some links and images.
 
CODE EXAMPLES
/app/stories/[filter]/page.tsx
/components/stories/storyArea.tsx
/components/stories/storyArea.tsx
/components/stories/storyCard.tsx
 

end result?

Very happy with the changes, I managed to shave off roughy 75% of load time.
notion image
notion image
 
I do need to do some more investigation though, as the lighthouse scores do not reflect.
notion image
notion image
 

coming back here after a refactor

I sat on this for a while and was not happy with the end result, it was good enough but so much potential room for improvement. What I initially tried to do was generateStaticParams as I was doing my data fetch at the page level with filtering via dynamic routes with url params. This however did not go to plan as static page retuned was over the Vercel 4mb threshold. Another flaw I found was the way I was fetching Author related data to each Story; This data would be fetched per each item at the component level still on the server but a request for every item.
 

how do we do this better?

In order to limit the refetching of data and better the caching of data this now needed to happen at a top level and only once. I now do a large fetch at the /stories page and then do filtering of that data on the client.
 
 
The above data is fetched on the server and on mount of our client component we set that data as a local state variable storyData and then do tag filtering on our local data.
 

do we see improvements?

We do! quite significant improvements on page speed insights, the results on mobile are still not perfect but getting much better. In relation to the previous performance graphs above we see a lot of improvement.
notion image
notion image
mobile performance graph
mobile performance graph
desktop performance graph
desktop performance graph
implemented changes on production.

other changes

I also removed all reference to prefetching as it was affecting LCP scores of the lading page. Images were compressed via squoosh to roughly 80kb.
  • This now means the images come in before their blurDataUrl’s can be calculated…
  • A solution here is to store a generic base 64 image to use as the blurData
 
export const revalidate = 30; export default function Page({ params }: { params: { filter: string } }) { return ( <section className={` mx-auto flex w-full max-w-[1280px] flex-col flex-wrap gap-4 px-2 pb-16 md:px-4 `} > <HiddenH1 title={"Stories"} /> <div className={"relative my-8 hidden md:flex"}> <Suspense fallback={<LatestStoryLoading />}> <LatestStory /> </Suspense> <HorizontalLine style={"top-0"} /> <HorizontalLine style={"bottom-0"} /> <VerticalLine style={"lg:-translate-x-[445px] -translate-x-[180px]"} /> <VerticalLine style={"lg:translate-x-[445px] translate-x-[180px]"} /> </div> <Suspense fallback={<StoryAreaLoading />}> <StoryArea filter={params.filter} /> </Suspense> </section> ); }
export async function getStoryData(filter?: string) { if (filter === "All") { let { data } = await supabaseAdmin .from("stories") .select() .order("date", { ascending: false }); return data; } else { let { data } = await supabaseAdmin .from("stories") .select() .contains("tags", `["${filter}"]`) .order("date", { ascending: false }); return data; } } export default async function StoryArea(props: { filter: string }) { const { filter } = props; const data = await getStoryData(filter); return ( <div className={"my-4 flex w-full flex-col gap-4"}> <StoryFilterArea /> <section className={`grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3`} > {data!.map((story, index) => { const baseUrl = "https://trinity.co.za/stories/"; const url = `${baseUrl}${encodeUrl(story?.description)}/${story.id}`; return ( <div key={index}> <StoryCard id={story.id} title={story.metaDataTitle} description={story.description} img={story.imgUrl} alt={story.imgAlt} date={story.date} tags={story.tags} /> <ArticleJsonLd useAppDir={true} url={url} title={story.title} images={[story.imgUrl]} datePublished={new Date(story!.date).toISOString()} authorName={story?.author.name} description={story?.description} /> </div> ); })} </section> </div> ); }
export const revalidate = 3600; + async function getStoryData(filter?: string): Promise<Story[]> { + if (filter === "All") { + let { data } = await supabaseAdmin + .from("stories") + .select() + .order("date", { ascending: false }); + return data as Story[]; + } else { + let { data } = await supabaseAdmin + .from("stories") + .select() + .contains("tags", `["${filter}"]`) + .order("date", { ascending: false }); + return data as Story[]; + } + } export default async function Page({ params }: { params: { filter: string } }) { const data = await getStoryData(params.filter); const author = await getAuthor(data[0].author); + return ( + <StoryPageWrapper> + <LatestStoryWrapper story={data[0]} author={author} /> + <StoryArea data={data} /> + </StoryPageWrapper> + ); }
+ export default function StoryArea(props: { data: Story[] }) { + return ( + <div className={"my-4 flex w-full flex-col gap-4"}> + <StoryFilterArea /> + + <StoryAreaWrapper> + <Suspense fallback={<StoryAreaLoading />}> + <StoryAreaCards data={props.data} /> + </Suspense> + </StoryAreaWrapper> + </div> + ); + }
+ interface StoryAreaCardsProps { + data: Story[]; + } + export const StoryAreaCards: FC<StoryAreaCardsProps> = ({ data }) => { + return ( + <Fragment> + {data!.map((story, index) => { + return ( + <Fragment key={index}> + <Suspense fallback={<LoadingCard />}> + <StoryCard + id={story.id} + title={story.metaDataTitle} + description={story.description} + img={story.imgUrl} + alt={story.imgAlt} + date={story.date} + tags={story.tags} + authorIndex={story.author} + priority={index <= 2} + /> + </Suspense> + </Fragment> + ); + })} + </Fragment> + ); + };
+ import { getAuthor } from "@/lib/apis"; interface StoryCardProps { id: string; title: string; description: string; img: string; alt: string; date: number; tags: string[]; + authorIndex: number; + priority: boolean; } export default async function StoryCard(props: StoryCardProps) { const { id, title, description, img, alt, date, tags, + priority, + authorIndex, } = props; + const author = await getAuthor(authorIndex); const blurDataUrl = await dynamicBlurDataUrl(img); + const baseUrl = "https://trinity.co.za/stories/"; + const url = `${baseUrl}${encodeUrl(description)}/${id}`; return ( <Fragment> <Link href={url} className={` flex h-full min-h-[540px] w-full cursor-pointer flex-col gap-6 rounded-2xl border border-neutral-50 bg-white p-4 shadow-sm duration-300 ease-in-out hover:shadow-md `} > <Image priority={priority} + src={img} alt={alt} sizes={"(min-width: 450px) 20vw"} placeholder={"blur"} height={300} width={300} blurDataURL={blurDataUrl} className={` relative flex h-80 w-full flex-shrink-0 overflow-clip rounded-md border border-neutral-100 object-cover shadow-md shadow-neutral-200/20 `} /> <div className={"flex h-full flex-col justify-between gap-4"}> <div className={"flex flex-col gap-1"}> <h2 className={`${frank.className} text-xl lg:text-2xl`}> {title} </h2> <p className={"md:text-md text-sm font-extralight"}> {description} </p> </div> <div className={"flex w-full items-center justify-between text-xs"}> <p>{getTimeAgo(date)}</p> <div className={"flex flex-row gap-1"}> {tags.map((tag, index) => { return ( <div key={index} className={`rounded-full bg-neutral-100 p-1 px-2`} > <p className={"text-[10px] text-neutral-600 md:text-xs"}> {tag} </p> </div> ); })} </div> </div> </div> </Link> + <ArticleJsonLd + useAppDir={true} + url={url} + title={title} + images={[img]} + datePublished={new Date(date).toISOString()} + authorName={author} + description={description} + /> </Fragment> ); }
async function getStoryData(): Promise<Story[]> { let { data } = await supabaseAdmin .from("stories") .select( ` *, author:Authors ( id, name, imgUrl, position, email ) `, ) .order("date", { ascending: false }); return data as Story[]; } export default async function Page() { const data = await getStoryData(); return <StoryPageWrapper data={data} />; }
Nifty ability I learnt with supabase that can call and write in foreign relation data.
// storyPageWrapper.tsx "use client"; import React, { FC, useEffect, useState } from "react"; import { HiddenH1 } from "@/components/misc/hiddenH1"; import { LatestStoryWrapper } from "@/components/stories/latestStoryWrapper"; import { Story } from "@/lib/types"; import { StoryArea } from "@/components/stories/storyArea"; interface StoryPageWrapperProps { data: Story[]; } export const StoryPageWrapper: FC<StoryPageWrapperProps> = ({ data }) => { const [storyData, setStoryData] = useState<Story[]>(data); const [filter, setFilter] = useState<string>("All"); useEffect(() => { if (filter == "All") { setStoryData(data); } else { const newData = data.filter((story) => story.tags.includes(filter)); setStoryData(newData); } }, [filter]); return ( <section className={` mx-auto flex w-full max-w-[1280px] flex-col flex-wrap gap-4 px-2 pb-16 md:px-4 `} > <HiddenH1 title={"Stories"} /> <LatestStoryWrapper story={storyData[0]} /> <StoryArea data={storyData} filterCallBack={(filter: string) => setFilter(filter)} filter={filter} /> </section> ); };