Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

how to invalidate all a pages of a [domain] route #379

Open
chrishoermann opened this issue Jan 5, 2024 · 0 comments
Open

how to invalidate all a pages of a [domain] route #379

chrishoermann opened this issue Jan 5, 2024 · 0 comments

Comments

@chrishoermann
Copy link

I'm currently building an application that is based on this repo with two applications in a monorepo where users can create sites on a separate dashboard app.

When a user updates e.g. his theme data I need to purge the cache for all pages of his subdomain or domain.
My current implementation looks like this

I use radix themes as a base color provider for tailwind. Radix Ui uses a Theme Provider that adds html attributes to a div that wrapps the whole application that looks like this:

      <div
        data-is-root-theme="true"
        data-accent-color="green"
        data-gray-color="slate"
        data-has-background="false"
        data-panel-background="solid"
        data-radius="medium"
        data-scaling="100%"
        class="radix-themes"
      />

Here's my layout that feeds the values from the database into the Theme provider:

import type { ReactNode } from "react";
import type { Metadata } from "next";
import { notFound, redirect } from "next/navigation";
import { Theme } from "@radix-ui/themes";

import { getPublicSiteData } from "@acme/api/src/fetchers/site";
import { CompanyThemeSchema } from "@acme/api/src/schemas/custom/theme";
import { prisma } from "@acme/db/src/db";

import { Navbar } from "~/components/nav/navbar";
import { Footer } from "~/components/sections/Footer";

/////////////////////////////////////////////////
// GENERATE STATIC PARAMS
/////////////////////////////////////////////////

export async function generateStaticParams() {
  const allSites = await prisma.site.findMany({
    select: {
      subdomain: true,
      customDomain: true,
    },
  });

  const allPaths = allSites
    .flatMap(({ subdomain, customDomain }) => [
      subdomain && {
        domain: `${subdomain}.${process.env.NEXT_RFK_ONLINE_ROOT_DOMAIN}`,
      },
      customDomain && {
        domain: customDomain,
      },
    ])
    .filter((value): value is RootParamsType =>
      Boolean((value as RootParamsType)?.domain),
    );

  return allPaths;
}

export interface RootParamsType {
  domain: string;
}

/////////////////////////////////////////////////
// GENERATE METADATA
/////////////////////////////////////////////////

export async function generateMetadata({
  params,
}: {
  params: RootParamsType;
}): Promise<Metadata | null> {
  const domain = decodeURIComponent(params.domain);

  const { data } = await getPublicSiteData(domain);

  if (!data) {
    return null;
  }

  const {
    name: title,
    description,
    image,
    logo,
  } = data as {
    name: string;
    description: string;
    image: string;
    logo: string;
  };

  return {
    title,
    description,
    openGraph: {
      title,
      description,
      images: [image],
    },
    twitter: {
      card: "summary_large_image",
      title,
      description,
      images: [image],
      creator: "@vercel",
    },
    icons: [logo],
    metadataBase: new URL(`https://${domain}`),
    // Optional: Set canonical URL to custom domain if it exists
    // ...(params.domain.endsWith(`.${process.env.NEXT_RFK_ONLINE_ROOT_DOMAIN}`) &&
    //   data.customDomain && {
    //     alternates: {
    //       canonical: `https://${data.customDomain}`,
    //     },
    //   }),
  };
}

/////////////////////////////////////////////////
// LAYOUT
/////////////////////////////////////////////////

export default async function SiteLayout({
  params,
  children,
}: {
  params: RootParamsType;
  children: ReactNode;
}) {
  const domain = decodeURIComponent(params.domain);
  const { data, error } = await getPublicSiteData(domain);

  if (!data || error) {
    notFound();
  }

  // Optional: Redirect to custom domain if it exists
  if (
    domain.endsWith(`.${process.env.NEXT_RFK_ONLINE_ROOT_DOMAIN}`) &&
    data.customDomain &&
    process.env.REDIRECT_TO_CUSTOM_DOMAIN_IF_EXISTS === "true"
  ) {
    return redirect(`https://${data.customDomain}`);
  }

  const theme = CompanyThemeSchema.parse(data.theme);

  return (
    <Theme {...theme}>
      <Navbar />
      <main className="min-h-screen">{children}</main>
      <Footer
        email={data.company?.email}
        phoneNumber={data.company?.phoneNumber}
        mobilePhone={data.company?.mobilePhone}
      />
    </Theme>
  );
}

The fetcher looks like this:

///////////////////////////////////////////////////////
// GET PUBLIC SITE DATA
///////////////////////////////////////////////////////

export async function getPublicSiteData(domain: string) {
  return await unstable_cache(
    async () => {
      return serverApi(null, async (api) => api.site.findPublicSite(domain));
    },
    [`${domain}-metadata`],
    { tags: [`${domain}-metadata`] },
  )();
}

export type GetSiteDataReturnType = RouterOutputs["site"]["findPublicSite"];

Which calls a trpc endpoint server side

export const siteRouter = createTRPCRouter({
  findPublicSite: publicProcedure
    .input(z.string())
    .query(async ({ input: domain, ctx }) => {
      const subdomain = extractSubdomain(domain);

      const site = await ctx.prisma.site.findUnique({
        where: subdomain ? { subdomain } : { customDomain: domain },
        include: {
          company: {
            include: {
              city: {
                include: {
                  district: {
                    include: {
                      state: {
                        include: {
                          country: true,
                        },
                      },
                    },
                  },
                },
              },
            },
          },
          theme: true,
        },
      });
      return site;
    }),
})

the revalidate site route handler looks like this:

import { revalidatePath, revalidateTag } from "next/cache";
import type { NextRequest } from "next/server";
import { NextResponse } from "next/server";
import { z } from "zod";

///////////////////////////////////////////
// TYPES
///////////////////////////////////////////

export type RevalidateThemeInputInputType = z.infer<
  typeof RevalidateThemeInputSchema
>;

export type RevalidateThemeRequestType = z.infer<
  typeof RevalidateThemeRequestSchema
>;

///////////////////////////////////////////
// SCHEMAS
///////////////////////////////////////////

export const RevalidateThemeInputSchema = z.object({
  domain: z.string(),
});

export const RevalidateThemeRequestSchema = RevalidateThemeInputSchema.extend({
  secret: z.literal(process.env.REVALIDATION_SECRET),
});

const PATHS_TO_REVALIDATE = [
  "/",
  "/wissenswertes",
  "/wissenswertes/[slug]",
  "/kontakt",
  "/impressum",
  "/cookies",
  "/datenschutz",
  "/beruf",
];

//////////////////////////////////////////////
// ROUTE HANDLERS
//////////////////////////////////////////////

export async function POST(req: NextRequest) {
  const body = (await req.json()) as unknown;

  const parsedRequest = RevalidateThemeRequestSchema.safeParse(body);

  if (!parsedRequest.success) {
    console.error(parsedRequest.error);

    return NextResponse.json({
      status: 400,
      body: {
        message: "Invalid request body",
        errors: parsedRequest.error,
      },
    });
  }

  const { domain } = parsedRequest.data;

  // Revalidate metadata so the site data is re-fetched
  const tag = `${domain}-metadata`;
  revalidateTag(tag);
  console.log("Revalidating tags: ", tag);

  // Revalidate all paths so each page gets the new theme data
  PATHS_TO_REVALIDATE.forEach((path) => {
    console.log("Revalidating paths: ", `${domain}${path}`);
    revalidatePath(`/${domain}${path}`, "layout");
    revalidatePath(`/${domain}${path}`, "page");
  });

  return NextResponse.json({
    status: 200,
    body: {
      message: "OK",
    },
  });
}

which is called with this fetcher in every endpoint that changes theme data:

//////////////////////////////////////////////
// FETCHER
//////////////////////////////////////////////

export async function revalidateTheme(data: RevalidateThemeInputInputType) {
  await fetch(
    `${process.env.NODE_ENV === "development" ? "http" : "https"}://${
      process.env.NEXT_RFK_ONLINE_ROOT_DOMAIN
    }/api/webhooks/revalidateTheme`,
    {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
      },
      body: JSON.stringify({
        ...data,
        secret: process.env.REVALIDATION_SECRET,
      }),
    },
  );
}

Everything is working so far except that the theme data is not updated on every page of the given domain when revalidation is triggered.

For example on https://mydomain the theme data looks like this after reloading the page:

      <div
        data-accent-color="green"
        ...
      />

and on other pages like https://mydomain/subpage the theme data still has the old color:

      <div
        data-accent-color="tomato"
       ...
      />

So how would I go about this?

The main page is updated correctly but all other paths keep having the old theme data. I assume this si because the theme is added in the root layout and therefore only the page in the same directory is purged from the cache and rebuilt with the new site data.

It also seems that the revalidatePath(/${domain}${path}, "layout"); is not doing anything. I assume this is because I use an absolute url paht like my-subdomain.at-my-domain.at/mypath and not the file system path as mentioned in the docs.

I also tried using the dynamic segment like [domain]/mypath but also no luck.

Any Ideas how this could be fixed so the theme is updated on all subpages as well?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

1 participant