Long TTFB on multilangual site. Prismic cache bug (probably)

Hi guys!

I've been struggling with the long TTFB on my website for several days and was hoping you could help me out. The issue is that the TTFB of the initial visit to every page takes a minimum of 500ms and usually 1-2s, whereas a static website typically has a TTFB of 30-50ms. I've tested this on both Netlify and Versel, and even locally it takes more than 300ms.

Here some pieces of my code:

prismicio.ts

import * as prismic from "@prismicio/client";
import * as prismicNext from "@prismicio/next";
import config from "../slicemachine.config.json";

/**
 * The project's Prismic repository name.
 */
export const repositoryName =
  process.env.NEXT_PUBLIC_PRISMIC_ENVIRONMENT || config.repositoryName;

// There is no way to automate the process of chnaging an URL from /de-de to /de or /en-us to /en,
// so no need to fetch locales and use the createLocaleRedirect function in the middleware.
// That's why the locales are hardcoded.
export const LOCALES = [
  { id: "en-us", name: "English - United States", is_master: true, slug: "en" },
  { id: "de-de", name: "German - Germany", is_master: false, slug: "de" },
] as (prismic.Language & { is_master: boolean; slug: string })[];

/**
 * A list of Route Resolver objects that define how a document's `url` field is resolved.
 *
 * {@link https://prismic.io/docs/route-resolver#route-resolver}
 */
// NOTE! needed to publish the first document of that type:
// https://community.prismic.io/t/prismic-with-nextjs-getsingle-unknown-type/12788
const routes: prismic.ClientConfig["routes"] = [
  {
    type: "homepage",
    lang: "en-us",
    path: "/",
  },
  {
    type: "homepage",
    lang: "de-de",
    path: "/de",
  },
  // Solution pages
  {
    type: "solution_page",
    lang: "en-us",
    path: "/solutions/:uid",
  },
  {
    type: "solution_page",
    lang: "de-de",
    path: "/de/solutions/:uid",
  },
  // Product pages
  {
    type: "product_page",
    lang: "en-us",
    path: "/products/:uid",
  },
  {
    type: "product_page",
    lang: "de-de",
    path: "/de/products/:uid",
  },
  // Industry pages
  {
    type: "industry_page",
    lang: "en-us",
    path: "/industries/:uid",
  },
  {
    type: "industry_page",
    lang: "de-de",
    path: "/de/industries/:uid",
  },
  // Resource library pages
  {
    type: "resource_library_page",
    lang: "en-us",
    path: "/resource-library/:uid",
  },
  {
    type: "resource_library_page",
    lang: "de-de",
    path: "/de/resource-library/:uid",
  },
  // Resource library category pages
  {
    type: "resource_library_category",
    lang: "en-us",
    path: "/resource-library/:uid",
  },
  {
    type: "resource_library_category",
    lang: "de-de",
    path: "/de/resource-library/:uid",
  },
  // Blog post pages
  {
    type: "blog_post",
    lang: "en-us",
    path: "/blog/:uid",
  },
  {
    type: "blog_post",
    lang: "de-de",
    path: "/de/blog/:uid",
  },
  // Blog category pages
  {
    type: "blog_category",
    lang: "en-us",
    path: "/blog/:uid",
  },
  {
    type: "blog_category",
    lang: "de-de",
    path: "/de/blog/:uid",
  },
  // Other pages
  {
    type: "page",
    lang: "en-us",
    path: "/:uid",
  },
  {
    type: "page",
    lang: "de-de",
    path: "/de/:uid",
  },
];

/**
 * Creates a Prismic client for the project's repository. The client is used to
 * query content from the Prismic API.
 *
 * @param config - Configuration for the Prismic client.
 */
export const createClient = (config: prismicNext.CreateClientConfig = {}) => {
  const client = prismic.createClient(repositoryName, {
    routes,
    accessToken: process.env.PRISMIC_ACCESS_TOKEN,
    fetchOptions:
      process.env.NODE_ENV === "production"
        ? { next: { tags: ["prismic"] }, cache: "force-cache" }
        : { next: { revalidate: 5 } },
    ...config,
  });

  prismicNext.enableAutoPreviews({
    client,
    previewData: config.previewData,
    req: config.req,
  });

  return client;
};

app/[lang]/[uid]/page.tsx

import Navigation from "@/components/Navigation/Navigation";
import TopBanner from "@/components/TopBanner";
import { getLocale } from "@/lib/getLocale";
import { createClient } from "@/prismicio";
import { components } from "@/slices";
import { isFilled } from "@prismicio/client";
import { SliceZone } from "@prismicio/react";
import { Metadata } from "next";
import { notFound } from "next/navigation";

type Params = { lang: string; uid: string };

export async function generateMetadata({
  params,
}: {
  params: Params;
}): Promise<Metadata> {
  const client = createClient();
  const locale = await getLocale(params.lang);
  const page = await client
    .getByUID("page", params.uid, {
      lang: locale,
    })
    .catch(() => null);

  if (!page) {
    return {};
  }
  const metadata = {} as Metadata;

  if (isFilled.keyText(page.data.meta_title))
    metadata.title = page.data.meta_title;
  if (isFilled.keyText(page.data.meta_description))
    metadata.description = page.data.meta_description;
  if (isFilled.image(page.data.meta_image))
    metadata.openGraph = { images: [page.data.meta_image.url] };

  return metadata;
}

export default async function Page({ params }: { params: Params }) {
  const client = createClient();
  const locale = await getLocale(params.lang);
  const globals = await client.getSingle("global_components", { lang: locale });
  const page = await client
    .getByUID("page", params.uid, { lang: locale })
    .catch(() => notFound());

  return (
    <>
      {isFilled.richText(globals.data.topbanner_text) && (
        <TopBanner {...globals.data} />
      )}

      <Navigation
        page={page}
        currentLocale={locale}
        isShadow={page.data.navigation_shadow}
        theme={page.data.navigation_style}
      />
      <SliceZone slices={page.data.slices} components={components} />
    </>
  );
}

export async function generateStaticParams({ params }: { params: Params }) {
  const client = createClient();
  const locale = await getLocale(params.lang);
  const pages = await client.getAllByType("page", {
    lang: locale,
  });

  return pages.map((page) => {
    return {
      uid: page.uid,
      lang: page.lang,
    };
  });
}

and lib/getLocale.ts (just for case)

import { LOCALES } from "@/prismicio";
import type { Language } from "@prismicio/client";

export async function getLocale(langSlug: string): Promise<Language["id"]> {
  const locale = LOCALES.find((locale) => locale.slug === langSlug);

  if (locale) return locale.id;

  const masterLocale = LOCALES.find((locale) => locale.is_master);
  if (masterLocale) return masterLocale.id;

  throw new Error("Master locale not found.");
}

As far as I understand, pages should be statically rendered during build and their content should be cached by next.js and updated on the "prismic" tag trigger, which is how the ISR works. Please correct me if I'm wrong. However, according to the Netlify logs, every request triggers SSR (I'm not sure if this is correct).


Please note, nothing were change in Prismic from yesterday.

I also tested a few examples from the prismic-community repository, and the results were surprising. The TTFB on this website - Todoop – Keep your life organized - averages between 200-500ms (please note that not much content is loaded from Prismic compared to my website) and is rarely 80-100ms, but never 30-50ms as with the static hosted.

It seems that websites with multiple languages that use Prismic have something wrong with caching and content fetches on every page load.

Any ideas?

Thanks

Hello,

Thanks for sharing this with the team. We plan on looking at it next week. Please let us know if you found a resolution until then.

1 Like

Any progress? :)

1 Like

Hi @Vladyslav.L, thank you for your patience. :slight_smile: We have an update.

As you discovered, websites supporting multiple locales using createLocaleRedirect from @prismicio/next always have a 200ms-500ms TTFB delay.

We found this happens because createLocaleRedirect fetches all of a Prismic repository's locales. The list of locales is used to detect and, if needed, prefix a locale to the URL.

Unfortunately, fetch calls made in middleware.js are not cached like calls in your website's pages. As a result, a request to fetch all of the Prismic repository's locales is made on every page load. This was an oversight in our testing when we created the createLocaleRedirect helper.

Going forward, we believe the best solution is to manage locales directly within a website's codebase. Such a solution will require maintaining a list of locales, but it will be much more performant than making an extra uncached network request on each page.

We are working on updating our documentation and multi-language starter with new guidelines. We will be deprecating @prismicio/next's createLocaleRedirect.

In the meantime, here is a draft version of our latest recommendation:

  1. Install the negotiator and @formatjs/intl-localematcher packages:

    npm install negotiator @formatjs/intl-localematcher
    
  2. Create a i18n.js file at the root of your project (or within src if you use that directory):

    import Negotiator from "negotiator";
    import { match } from "@formatjs/intl-localematcher";
    
    /**
     * A record of locales mapped to a version displayed in URLs. The first entry is
     * used as the default locale.
     */
    const LOCALES = {
      "en-us": "en",
      "fr-fr": "fr",
    };
    
    /**
     * Creates a redirect with an auto-detected locale prepended to the URL.
     *
     * @param request {import("next/server").NextRequest}
     * @returns {Response}
     */
    export function createLocaleRedirect(request) {
      const headers = { "accept-language": request.headers.get("accept-language") };
      const languages = new Negotiator({ headers }).languages();
      const locales = Object.keys(LOCALES);
      const locale = match(languages, locales, locales[0]);
    
      request.nextUrl.pathname = `/${LOCALES[locale]}${request.nextUrl.pathname}`;
    
      return Response.redirect(request.nextUrl);
    }
    
    /**
     * Determines if a pathname has a locale as its first segment.
     *
     * @param request {import("next/server").NextRequest}
     * @returns {boolean}
     */
    export function pathnameHasLocale(request) {
      const regexp = new RegExp(`^/(${Object.values(LOCALES).join("|")})(\/|$)`);
    
      return regexp.test(request.nextUrl.pathname);
    }
    
    /**
     * Returns the full locale of a given locale. It returns `undefined` if the
     * locale is not in the master list.
     *
     * @param locale {string}
     * @returns {string | undefined}
     */
    export function reverseLocaleLookup(locale) {
      for (const key in LOCALES) {
        if (LOCALES[key] === locale) {
          return key;
        }
      }
    }
    
  3. Create or modify your middleware.js file with the following:

    import { createLocaleRedirect, pathnameHasLocale } from "@/i18n";
    
    /**
     * @param request {import("next/server").NextRequest}
     */
    export async function middleware(request) {
      if (!pathnameHasLocale(request)) {
        return createLocaleRedirect(request);
      }
    }
    
    export const config = {
      matcher: ["/((?!_next|api|slice-simulator|icon.svg).*)"],
    };
    
  4. Update the route resolver in prismicio.js to include :lang and/or custom locales. The following example matches the LOCALES record in i18n.js.

    export const routes = [
      { type: "page", path: "/:lang/:uid" },
      { type: "page", lang: "en-us", path: "/en/:uid" },
      { type: "page", lang: "fr-fr", path: "/fr/:uid" },
    
      // Treat the `home` UID as the root route.
      { type: "page", uid: "home", path: "/:lang" },
      { type: "page", uid: "home", lang: "en-us", path: "/en" },
      { type: "page", uid: "home", lang: "fr-fr", path: "/fr" },
    ];
    

If any of that is unclear, please let me know and I'll try to explain it. :slight_smile:

1 Like
nevermind

Thanks for the reply!

Glad you found an issue and solution.

Howerver, I've managed it by rewrites() function:

async rewrites() {
    return [
      {
        source: "/de/:path*",
        destination: "/de/:path*",
      },
      {
        source: "/de",
        destination: "/de",
      },
      {
        source: "/",
        destination: "/en-us",
      },
      {
        source: "/:path*",
        destination: "/en-us/:path*",
      },
    ];
  },

Can you confirm it won't be a problem with Prismic Preview or anything else?

It looks like the rewrites() function makes requests to be uncached or non-cacheble. The problem is back and become even worse.

Have you tried the above solution using middleware?

It matches the Next.js documentation's recommendation: Routing: Internationalization | Next.js

Yep, it works perfect and I marked it as a Solution. Thanks a lot!

1 Like

Awesome! Glad to hear that. :smile: Thanks for letting us know!