Next.js, Projects
Apr 25, 2023
EDEPO Asesores Technology Overview
Technology architecture for the project EDEPO
Let an AI answer your questions
I was contacted by the accounting firm EDEPO to developed a modern, premium and professional website, that would differentiate the firm from its competitors. It had to be fast, visually appealing and look and feel modern. It had to include a blog, newsletter functionality, i18n and a headless CMS with admin panel.
We chose a technology stack that would fulfil those requirements and provide great developer experience.
Front-end
The front-end is based on the following technologies:
- React.
- ChakraUI: component library for React that provides a great API and design system.
- SWR: used for client side data fetching. Provides caching, among other features.
- react-i18next: library used for i18n functionality.
- Framer Motion: animation library with a great declarative API.
The front-end utilizes several techniques to improve performance. These are some examples:
React Suspense and lazy loading of heavy elements such as video:
import { Flex, Spinner, Box, useColorModeValue } from "@chakra-ui/react";
import dynamic from "next/dynamic";
import { Suspense } from "react";
const DynamicVideo = dynamic(() => import("./video"), {
suspense: true,
});
export function Banner({ lng }: Props) {
// Other code
const backgroundColorOveraly = useColorModeValue("rgba(255,255,255, 0.7)", "transparent");
return (
// Other code
<Suspense
fallback={
<Flex w="100%" h="100%" align="center" justify="center" zIndex={-1} position="absolute">
<Spinner size="xl" color="orange.500" />
</Flex>
}
>
<Box w="100%" h="100%" backgroundColor={backgroundColorOveraly} position="absolute">
<DynamicVideo />
</Box>
</Suspense>
// Other code
);
}
Separation of components in React based on state, to prevent re-renders. SWR to cache client-side requests:
export function BlogPosts({ searchActionStr, selectedTags, postsFirstPage, postFirstDestacado, lng }: Props) {
// Receives state from parent component including a Search String and Selected Tags
// State Handling for pagination
const [[pageIndex, direction], setPageIndex] = useState([1, 0]);
// Query builder to return a specific page of Posts
const pagePostQuery = qs.stringify(
{
sort: ["Date:desc"],
fields: ["Title", "Date", "createdAt", "updatedAt", "Destacado", "Slug"],
populate: "*",
pagination: {
page: pageIndex,
pageSize: 8,
},
},
{ encodeValuesOnly: true }
);
// Main SWR Data fetch
const { data, error } = useSWR(`${process.env.NEXT_PUBLIC_STRAPI_URL}/posts?${pagePostQuery}`, fetcher, {
fallbackData: postsFirstPage,
});
// Filter Post "Destacado" if we get only 1 page total. If there are more than 1 page, do not filter.
const totalPages = data.meta.pagination.pageCount;
const filteredPosts: Post[] =
totalPages === 1
? data?.data.filter((post: Post) => {
return post.id !== postFirstDestacado.data[0]?.id;
})
: data?.data;
// Pagination handler
function handleClick(e: React.MouseEvent<HTMLButtonElement>) {
e.preventDefault();
if (e.currentTarget.id === "next") {
setPageIndex([pageIndex + 1, 1]);
} else if (e.currentTarget.id === "prev") {
setPageIndex([pageIndex - 1, -1]);
} else {
setPageIndex([parseInt(e.currentTarget.id), parseInt(e.currentTarget.id) - pageIndex]);
}
}
// Query that executes when the user searches. It gets all posts with only the fields needed for the search.
const pagePostsSearchQuery = qs.stringify(
{
sort: ["Date:desc"],
fields: ["Title", "Slug"],
populate: ["tags"],
},
{ encodeValuesOnly: true }
);
const { data: searchPosts, error: searchPostsError } = useSWR(
searchActionStr.length > 0 || selectedTags.length > 0
? `${process.env.NEXT_PUBLIC_STRAPI_URL}/posts?${pagePostsSearchQuery}`
: null,
fetcher
);
// Function that performs a simple search algorithm on the posts that are returned by the query above. After matching the search, this function returns a new query string for use in the final SWR query.
function executeSearchAndGetQuery() {
const filteredPosts = searchPosts?.data.filter((post: SearchPost) => {
const isInTitle =
searchActionStr.length > 0
? normalizeStr(post.attributes.Title).includes(normalizeStr(searchActionStr))
: false;
const isInTag =
selectedTags.length > 0
? selectedTags.every((tag) => {
return post.attributes.tags.data
.map((el) => {
return el.attributes.TagName;
})
.includes(tag);
})
: false;
return isInTitle || isInTag;
});
const searchPostsIds = filteredPosts.length > 0 ? filteredPosts.map((post: SearchPost) => post.id) : null;
return qs.stringify(
{
sort: ["Date:desc"],
filters: {
id: {
$eq: searchPostsIds,
},
},
fields: ["Title", "Date", "createdAt", "updatedAt", "Destacado", "Slug"],
populate: "*",
pagination: {
page: pageIndex,
pageSize: 8,
},
},
{ encodeValuesOnly: true }
);
}
const { data: searchResults, error: searchResultsError } = useSWR(
searchPosts ? () => `${process.env.NEXT_PUBLIC_STRAPI_URL}/posts?${executeSearchAndGetQuery()}` : null,
fetcher
);
// Set final values of posts to display, depending on wether a search was performed or not.
const postsToShow: Post[] = searchResults ? searchResults.data : filteredPosts;
const postsToShowMeta = searchResults ? searchResults.meta : data.meta;
return (
// Template
)
}
Back-end
The back-end is based on the following technologies:
Several rendering methods were used to optimize speed, along with other techniques.
Incremental Static Regeneration
A serverless function/API with authentication was exposed to communicate with Strapi.js CMS to perform static regeneration when needed (blog CUD operations), instead of fetching data per request.
import { NextApiRequest, NextApiResponse } from "next";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
// Check for secret to confirm this is a valid request
if (req.query.secret !== process.env.SECRET_TOKEN_BLOG_POST_ROUTE_REVALIDATION) {
return res.status(401).json({ message: "Invalid token" });
}
try {
const slug = req.query.slug;
if (!slug) {
await res.revalidate("/");
await res.revalidate("/blog");
return res.json({ revalidated: true });
} else {
await res.revalidate("/");
await res.revalidate(`/blog/${slug}`, {
unstable_onlyGenerated: false,
});
return res.json({ revalidated: true });
}
} catch (err) {
// If there was an error, Next.js will continue
// to show the last successfully generated page
console.log(err);
return res.status(500).send("Error revalidating");
}
}
Static Site Generation
Pages that do not depend on dynamic data are generated ahead of time for performance.
Route prefetching
i18n was introduced with a custom middleware in Next.js 13.
import { NextRequest, NextResponse } from "next/server";
import acceptLanguage from "accept-language";
import { fallbackLng, languages } from "./app/i18n/settings";
acceptLanguage.languages(languages);
// Define routes where middleware should run
export const config = {
matcher: ["/((?!api|static|.*\\..*|_next).*)"],
};
const cookieName = "i18next";
export function middleware(req: NextRequest) {
let lng;
if (req.cookies.has(cookieName)) lng = acceptLanguage.get(req.cookies.get(cookieName)?.value);
// if (!lng) lng = acceptLanguage.get(req.headers.get("Accept-Language"));
if (!lng) lng = fallbackLng;
if (
!languages.some((loc) => req.nextUrl.pathname.startsWith(`/${loc}`)) &&
!req.nextUrl.pathname.startsWith("/_next")
) {
return NextResponse.redirect(new URL(`/${lng}${req.nextUrl.pathname}${req.nextUrl.search}`, req.url));
}
if (req.headers.has("referer")) {
const refererUrl = new URL(req.headers.get("referer") as string);
const lngInReferer = languages.find((l) => refererUrl.pathname.startsWith(`/${l}`));
const response = NextResponse.next();
if (lngInReferer) response.cookies.set(cookieName, lngInReferer);
return response;
}
return NextResponse.next();
}
Strapi.js
Declarative custom lifecycle hooks were written to add functionality when some queries are called:
- Prevent adding more than 2 tags per post on create and update.
- Send emails to newsletter subscriptions.
- Send per-route regeneration requests to Next.js for ISR on CUD operations.
Example:
import utils from "@strapi/utils";
const { ApplicationError } = utils.errors;
async function revalidateTags() {
try {
const res = await fetch(
`${process.env.NEXT_SERVER_REVALIDATE_API}?secret=${process.env.NEXT_SERVER_REVALIDATE_TOKEN}`,
{
method: "GET",
}
);
if (!res.ok) {
console.log("Error revalidating cache data on Next.js");
} else {
console.log("Revalidation successful");
}
} catch (error) {
console.log(error);
}
}
export default {
async beforeCreate(event) {
// Prevent tag creation if adding more than 2 post relations
const { data } = event.params;
// Check if user attempts to connect posts to a tag in tag creation. If not, do nothing.
if (data.posts.connect.length > 0) {
// Use Strapi Service to get current posts that are being attempted to be connected to the tag
const idArray = data.posts.connect.map((tagObject) => tagObject.id);
const postsToConnect = await strapi.service("api::post.post").find({
filters: { id: { $in: idArray } },
populate: ["tags"],
});
// Access each current post and create an array containing the number of tags each post has
const currentTagsLengthArr = postsToConnect["results"].map((post) => post.tags.length);
// Check that each final length of each post would not be higher than 2. Remember than in create, we are only creating 1 tag at a time
const isHigherThanTwo = currentTagsLengthArr.some((length) => length + 1 > 2);
// Throw a Strapi Application Error if any of the posts would have more than 2 tags
if (isHigherThanTwo) {
throw new ApplicationError("You can only add 2 tags per post", {
maxTags: 2,
});
}
}
},
async beforeUpdate(event) {
// Prevent tag update if adding more than 2 post relations
const { data } = event.params;
// Check if user attempts to connect posts to a tag in tag update. If not, do nothing.
if (data.posts.connect.length > 0) {
// Use Strapi Service to get current posts that are being attempted to be connected to the tag
const idArray = data.posts.connect.map((tagObject) => tagObject.id);
const postsToConnect = await strapi.service("api::post.post").find({
filters: { id: { $in: idArray } },
populate: ["tags"],
});
// Access each current post and create an array containing the number of tags each post has
const currentTagsLengthArr = postsToConnect["results"].map((post) => post.tags.length);
// Check that each final length of each post would not be higher than 2. Remember than in update, we are only updating 1 tag at a time
const isHigherThanTwo = currentTagsLengthArr.some((length) => length + 1 > 2);
// Throw a Strapi Application Error if any of the posts would have more than 2 tags
if (isHigherThanTwo) {
throw new ApplicationError("You can only add 2 tags per post", {
maxTags: 2,
});
}
}
},
async afterCreate(event) {
await revalidateTags();
},
async afterDelete(event) {
await revalidateTags();
},
async afterDeleteMany(event) {
await revalidateTags();
},
async afterUpdate(event) {
await revalidateTags();
},
};
Conclusion
The stack described and implementation of adequate techniques and patterns resulted in a website that meets requirements and is fast, good looking and feels premium.