Dvartic

BlogProjectsContact

GitHub

Post Image

Next.js, Projects

Apr 25, 2023

EDEPO Asesores Technology Overview

Technology architecture for the project EDEPO

Let an AI answer your questions

edepo.es

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.