Florian Dupuis

Go back

Extime.com

This one is like my baby. I coded the first line in 2021 and I am still working on this project. This is the project that taught me everything about Next.js from version 11 to version 14 nowadays. It has brought me to 2 React.js conferences and 3 Next.js conferences because I always want to make this app as optimized and up-to-date as possible. This app is a mixture of internal APIs (flight search, parking search and reservation, offers search, authentication) and Strapi CMS for managing content. I also learned a lot about Strapi by developing some private plugins for our needs.

My stack for this project

The app allows customers to order products from the duty-free before their flight. They need to enter their flight information, and the catalog will be updated based on the availability of products at the flight terminal. Depending on the final flight destination, displayed prices will vary, either excluding or including taxes, or with different promotions based on the customer's departure terminal. Customers can also book a parking spot according to their flight information.

Visit the marketplace

Since we succeed to redesign the Paris Aéroports website homepage with our stack, we decided to based the Extime marketplace on Next.js and Tailwind CSS. Tailwind contribute to reduce our integration time. Since 2021, I only work with on additional frontend developer but I developed 95% of the Extime features, components, animations. I was feeling like it was a big amount of work for a single developer but my product owner did a awesome job by organizing my tasks every two weeks (duration of a sprint).

Design was an aspect because we were looking the pixel perfect integration but we had much more technical subjects more about Next.js and the backend features and performance because it's a very high trafic website and subjects to be the target of hackers.

I have also defined a distinct way to develop our components in order to limit class overload and make customization as restrictive as possible. This is how I have been developing all my reusable components since 2021.

@/common/button.tsx
import { Dispatch, ReactElement, ReactNode, SetStateAction } from "react";
import Link from "next/link";
import classNames from "classnames";
import { IconType } from "react-icons";

enum Variant {
PRIMARY,
SECONDARY,
}

enum Size {
SMALL,
NORMAL,
BASE,
LARGE,
}

enum Align {
LEFT,
RIGHT,
CENTER,
}

enum Width {
FULL,
AUTO,
}

enum Rounded {
MEDIUM,
FULL,
}

interface ButtonProps {
id?: string;
variant?: Variant;
hashId?: string;
type?: "button" | "submit";
children: ReactNode;
size?: Size;
align?: Align;
width?: Width;
rounded?: Rounded;
href?: string;
target?: boolean;
disabled?: boolean;
onClick?: Dispatch<SetStateAction<any>>;
loading?: boolean;
ariaLabel?: string;
icon?: IconType;
ref?: any;
}

const SIZE_MAPS: Record<Size, string> = {
[Size.SMALL]: "text-small font-medium p-3",
[Size.NORMAL]: "text-normal font-medium p-3",
[Size.BASE]: "text-normal font-medium p-4",
[Size.LARGE]: "text-normal font-medium p-5",
};

const VARIANT_MAPS: Record<Variant, string> = {
[Variant.PRIMARY]: "bg-primary border-transparent text-white hover:bg-primary/80 ",
[Variant.SECONDARY]: "bg-secondary border-transparent text-white hover:bg-secondary/80 ",
};

const ALIGN_MAPS: Record<Align, string> = {
[Align.LEFT]: "mr-auto",
[Align.RIGHT]: "ml-auto",
[Align.CENTER]: "md:w-auto mx-auto",
};

const WIDTH_MAPS: Record<Width, string> = {
[Width.FULL]: "block w-full",
[Width.AUTO]: "block",
};

const ROUNDED_MAPS: Record<Rounded, string> = {
[Rounded.MEDIUM]: "rounded-md",
[Rounded.FULL]: "rounded-full",
};

export function Button(props: ButtonProps): ReactElement | null {
  const {
    hashId,
    type,
    align,
    width,
    href,
    disabled,
    loading,
    onClick,
    target,
    ariaLabel,
    variant = Variant.PRIMARY,
    size = Size.SMALL,
    rounded = Rounded.MEDIUM,
    children,
  } = props;

const renderButton = (

<button
    type={type}
    id={hashId}
    className={classNames(
    "whitespace-no-wrap relative items-center border leading-none transition duration-500 ease-in-out disabled:cursor-not-allowed disabled:opacity-50 lg:px-[35px]",
    VARIANT_MAPS[variant!],
    SIZE_MAPS[size!],
    ALIGN_MAPS[align!],
    WIDTH_MAPS[width!],
    ROUNDED_MAPS[rounded!]
    )}
    onClick={onClick}
    disabled={disabled}
    aria-label={ariaLabel}
>

{loading && (
<svg.../>
)}

<span className="relative">
    {props.icon && <props.icon className="absolute -left-3 top-0 h-5 w-5" />}
        <span className={classNames(loading && "invisible", props.icon && "pl-3")}>{children}</span>
    </span>
</button>
);

if (href)
return (

<Link className={classNames(WIDTH_MAPS[width!])} href={href} target={target ? "_blank" : "_self"}>
    {renderButton}
</Link>
);

return renderButton;

}

Button.type = "button";
Button.size = Size;
Button.align = Align;
Button.width = Width;
Button.rounded = Rounded;
Button.variant = Variant;

Then you can simply use the button like this

<Button
    type="submit"
    width={Button.width.FULL}
    size={Button.size.LARGE}
    loading={isSubmitting}
    disabled={isSubmitting || !isValid}
>
    Générer le fichier
</Button>

We aimed for the website to have low API consumption. There are more than 250 pages in French and English statically built using the incremental static website generation. This defers the API calls at build time, and we use Strapi webhooks and Next.js API revalidation to update the page on demand. The system is still based on the pages app, but we are currently working on an app directory refactoring.

Here is an example of revalidation page api reading webhook body

/api/revalidate
import { fetchAPI, getStrapiDataFromJSON } from "@helpers/app/strapi";
import { logger } from "@utils/logger";
import { NextApiResponse } from "next";
import { StrapiRedirection } from "types";

export default async function revalidateSinglePage(entry: any, res: NextApiResponse) {
    try {
        const { relative_url, locale } = entry;

        logger.info("SINGLE_PAGE_REVALIDATE:START /" + locale + relative_url);
        await res.revalidate("/" + locale + relative_url); // on revalide la page
        logger.info("SINGLE_PAGE_REVALIDATE:DONE  /" + locale + relative_url);

        setTimeout(async () => {
            const redirections = await getStrapiDataFromJSON("redirections", locale, false);
            const foundPageToRemove = redirections.data.find((redirection: StrapiRedirection) => redirection.attributes.destination === relative_url && !redirection.attributes.revalidated);
            if (foundPageToRemove) {
                const oldUrl = "/" + locale + foundPageToRemove.attributes.source;

                logger.info("OLD_PAGE_REMOVE:START " + oldUrl);
                await res.revalidate(oldUrl);
                logger.info("OLD_PAGE_REMOVE:DONE  " + oldUrl);

                await fetchAPI(
                    "/redirections/" + foundPageToRemove.id,
                    locale,
                    {
                        data: {
                            revalidated: true,
                        },
                    },
                    false,
                    "PUT"
                );
                logger.info("OLD_PAGE_CREATE_REDIRECTION:DONE " + oldUrl);
            }
        }, 5000);
    } catch (err) {
        logger.error(JSON.stringify(err));
    }

}
revalidateSinglePage()
import { fetchAPI, getStrapiDataFromJSON } from "@helpers/app/strapi";
import { logger } from "@utils/logger";
import { NextApiResponse } from "next";
import { StrapiRedirection } from "types";

export default async function revalidateSinglePage(entry: any, res: NextApiResponse) {
    try {
        const { relative_url, locale } = entry;

        logger.info("SINGLE_PAGE_REVALIDATE:START /" + locale + relative_url);
        await res.revalidate("/" + locale + relative_url); // on revalide la page
        logger.info("SINGLE_PAGE_REVALIDATE:DONE  /" + locale + relative_url);

        setTimeout(async () => {
            const redirections = await getStrapiDataFromJSON("redirections", locale, false);
            const foundPageToRemove = redirections.data.find((redirection: StrapiRedirection) => redirection.attributes.destination === relative_url && !redirection.attributes.revalidated);
            if (foundPageToRemove) {
                const oldUrl = "/" + locale + foundPageToRemove.attributes.source;

                logger.info("OLD_PAGE_REMOVE:START " + oldUrl);
                await res.revalidate(oldUrl);
                logger.info("OLD_PAGE_REMOVE:DONE  " + oldUrl);

                await fetchAPI(
                    "/redirections/" + foundPageToRemove.id,
                    locale,
                    {
                        data: {
                            revalidated: true,
                        },
                    },
                    false,
                    "PUT"
                );
                logger.info("OLD_PAGE_CREATE_REDIRECTION:DONE " + oldUrl);
            }
        }, 5000);
    } catch (err) {
        logger.error(JSON.stringify(err));
    }

}

The main challenge with revalidation is that the app runs on three server instances. When we trigger revalidation through webhook events, we have to revalidate all three instances. For this, we use Azure Service bus to received message from Strapi and dispatch these to the Next.js app. To listen the received message, we add to develop a small custom server.js file.

connectAzureBus() function into server.js file
const {(ServiceBusClient, ServiceBusAdministrationClient)} = require("@azure/service-bus")
const axios = require("axios") 
const {getServerUrl} = require("../../utils/get-server-url")
const {logger} = require("../../utils/logger")

async function connectAzureBus() {
const { serverUrl } = getServerUrl()

    const connectionString = process.env.SERVICE_BUS_CONNECTION_STRING_APP_LISTEN
    const topicName = process.env.SERVICE_BUS_TOPIC_NAME
    const subscriptionName = 'mkpl_app_' + Date.now()

    const serviceBusAdministrationClient = new ServiceBusAdministrationClient(connectionString)

    await serviceBusAdministrationClient.createSubscription(topicName, subscriptionName, { autoDeleteOnIdle: "PT5M" }).catch(error => {
    	logger.error(JSON.stringify(error))
    })
    const sbClient = new ServiceBusClient(connectionString)

    const receiver = sbClient.createReceiver(topicName, subscriptionName)

    const myMessageHandler = async messageReceived => {
    	const { body } = messageReceived
    	logger.info('AZURE_BUS ' + JSON.stringify(body))
    	await axios.post(serverUrl + '/api/webhook/revalidate', body, {
    		headers: {
    			Authorization: "Bearer" + process.env.APP_API_KEY,
    		},
    	})
    }

    const myErrorHandler = async error => {
    	logger.error(JSON.stringify(error))
    }

    receiver.subscribe({
    	processMessage: myMessageHandler,
    	processError: myErrorHandler,
    })

}

Contributions to Open Source Strapi plugins

Futhermore, I also do some contributions on two Strapi plugin strapi-plugin-preview-button and strapi-plugin-duplicate-button because when I was using these plugins I discovered some issue and missing features for our needs and the community. My pull requests are attached to the links. These are small contributions but I am proud of these. I wish I can do more open-source contribution in the futur but I am missing time right now.

FR