cache queries and revalidate with incremental static regeneration

This commit is contained in:
2026-07-07 02:32:21 +02:00
parent d63b14b2e5
commit e600d0a663
22 changed files with 381 additions and 114 deletions
-2
View File
@@ -8,8 +8,6 @@ import {
} from "@/components/general/GenericPageView";
import { getSeoMetadata } from "@/lib/seo";
export const dynamicParams = false;
function getWagtailUrlPath(url: string[]): string {
// for the page /foo/bar we need to look for `/home/foo/bar/`
return `/home/${url.join("/")}/`;
+2 -24
View File
@@ -1,36 +1,14 @@
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import {
NewsPageView,
loadNewsPageProps,
} from "@/components/news/NewsPageView";
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo";
export async function generateStaticParams() {
const allNewsSlugsQuery = graphql(`
query allNewsSlugs {
pages(contentType: "news.NewsPage") {
id
slug
}
}
`);
const { data, error } = await getClient().query(allNewsSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /aktuelt"
);
}
return data.pages.map((page: any) => ({
slug: page.slug,
}));
// Prerender nothing at build time; render and cache on first request
return [];
}
type Params = Promise<{ slug: string }>;
+28
View File
@@ -0,0 +1,28 @@
import { timingSafeEqual } from "node:crypto";
import { revalidateTag } from "next/cache";
import { NextRequest, NextResponse } from "next/server";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
function secretMatches(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}
export async function POST(req: NextRequest) {
const secret = process.env.REVALIDATE_WEBHOOK_SECRET;
if (!secret) {
return NextResponse.json(
{ error: "revalidation is not configured" },
{ status: 503 }
);
}
const provided = req.headers.get("x-revalidate-secret") ?? "";
if (!secretMatches(provided, secret)) {
return NextResponse.json({ error: "invalid secret" }, { status: 401 });
}
// expire: 0 purges immediately; the next request renders fresh
revalidateTag(CMS_CACHE_TAG, { expire: 0 });
return NextResponse.json({ revalidated: true, tag: CMS_CACHE_TAG });
}
+6 -1
View File
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import {
eventsOverviewQuery,
getSingularEvents,
@@ -45,7 +46,11 @@ export async function GET(req: NextRequest) {
);
}
const { data, error } = await getClient().query(eventsOverviewQuery, {});
const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) {
throw new Error(error.message);
}
+2 -24
View File
@@ -1,36 +1,14 @@
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { getClient } from "@/app/client";
import {
EventPageView,
loadEventPageProps,
} from "@/components/events/EventPageView";
import { graphql } from "@/gql";
import { getSeoMetadata } from "@/lib/seo";
export async function generateStaticParams() {
const allEventSlugsQuery = graphql(`
query allEventSlugs {
pages(contentType: "events.EventPage") {
id
slug
}
}
`);
const { data, error } = await getClient().query(allEventSlugsQuery, {});
if (error) {
throw new Error(error.message);
}
if (!data?.pages) {
throw new Error(
"Failed to generate static params for subpages of /arrangementer"
);
}
return data.pages.map((page: any) => ({
slug: page.slug,
}));
// Prerender nothing at build time; render and cache on first request
return [];
}
type Params = Promise<{ slug: string }>;
+5 -2
View File
@@ -3,6 +3,8 @@ import "server-only";
import { cacheExchange, createClient, fetchExchange } from "@urql/core";
import { registerUrql } from "@urql/next/rsc";
import { CMS_CACHE_TAG } from "@/lib/revalidation";
const wagtailBaseUrl = process.env.WAGTAIL_BASE_URL;
if (!wagtailBaseUrl) {
throw new Error("WAGTAIL_BASE_URL is not set");
@@ -13,8 +15,9 @@ const makeClient = () => {
return createClient({
url: graphqlEndpoint,
exchanges: [cacheExchange, fetchExchange],
// requestPolicy: "network-only",
fetchOptions: { next: { revalidate: 0 } },
// Cache all queries in the Data Cache until revalidateTag(CMS_CACHE_TAG);
// override per operation with the contexts in @/lib/revalidation
fetchOptions: { cache: "force-cache", next: { tags: [CMS_CACHE_TAG] } },
});
};
+6 -1
View File
@@ -1,4 +1,5 @@
import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation";
import { PreviewBanner } from "@/components/general/PreviewBanner";
import {
AssociationIndexView,
@@ -149,7 +150,11 @@ export default async function PreviewRender() {
return <ExpiredPreview />;
}
const { data, error } = await getClient().query(previewPageQuery, { token });
const { data, error } = await getClient().query(
previewPageQuery,
{ token },
uncached
);
if (error) {
throw new Error(error.message);
}
+2 -1
View File
@@ -1,4 +1,5 @@
import { getClient } from "@/app/client";
import { uncached } from "@/lib/revalidation";
import {
type SearchResult,
SearchResults,
@@ -65,7 +66,7 @@ export default async function Page({
}
`);
const { data } = await getClient().query(searchQuery, { query });
const { data } = await getClient().query(searchQuery, { query }, uncached);
const all = (data?.results ?? []) as SearchResult[];
totalCount = all.length;
results = all.slice(0, RESULT_LIMIT);
+6 -1
View File
@@ -1,5 +1,6 @@
import { VenueFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventContainer } from "@/components/events/EventContainer";
import { PageHeader } from "@/components/general/PageHeader";
import {
@@ -17,7 +18,11 @@ export type EventIndexViewProps = {
};
export async function loadEventIndexProps(): Promise<EventIndexViewProps> {
const { data, error } = await getClient().query(eventsOverviewQuery, {});
const { data, error } = await getClient().query(
eventsOverviewQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
if (
!data?.index ||
+6 -1
View File
@@ -2,6 +2,7 @@ import Link from "next/link";
import { graphql } from "@/gql";
import { HomeFragment } from "@/gql/graphql";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { EventListItemFragment } from "@/lib/event";
import { NewsListItemFragment } from "@/lib/news";
import { FeaturedEvents } from "@/components/events/FeaturedEvents";
@@ -55,7 +56,11 @@ export type HomePageViewProps = {
export async function loadHomePageProps(overrides?: {
homeOverride?: HomeFragment;
}): Promise<HomePageViewProps> {
const { data, error } = await getClient().query(homeQuery, {});
const { data, error } = await getClient().query(
homeQuery,
{},
cachedUntilOsloMidnight()
);
if (error) throw new Error(error.message);
const home = overrides?.homeOverride ?? (data?.home as HomeFragment | undefined);
if (!home) throw new Error("Failed to load /");
-12
View File
@@ -15,8 +15,6 @@ import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/
*/
type Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": typeof types.AllGenericSlugsDocument,
"\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n ": typeof types.AllNewsSlugsDocument,
"\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n ": typeof types.AllEventSlugsDocument,
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": typeof types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": typeof types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": typeof types.PreviewPageDocument,
@@ -85,8 +83,6 @@ type Documents = {
};
const documents: Documents = {
"\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n ": types.AllGenericSlugsDocument,
"\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n ": types.AllNewsSlugsDocument,
"\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n ": types.AllEventSlugsDocument,
"\n query allAssociationSlugs {\n pages(contentType: \"associations.AssociationPage\") {\n id\n slug\n }\n }\n ": types.AllAssociationSlugsDocument,
"\n query allVenueSlugs {\n pages(contentType: \"venues.VenuePage\", limit: 100) {\n id\n slug\n }\n }\n ": types.AllVenueSlugsDocument,
"\n query previewPage($token: String!) {\n page: page(token: $token) {\n __typename\n ... on GenericPage {\n ...Generic\n }\n ... on StudioPage {\n ...Studio\n }\n ... on SponsorsPage {\n ...SponsorsPage\n }\n ... on HomePage {\n ...Home\n }\n ... on EventPage {\n ...Event\n }\n ... on NewsPage {\n ...News\n }\n ... on AssociationPage {\n ...Association\n }\n ... on VenuePage {\n ...Venue\n }\n ... on NewsIndex {\n ...NewsIndex\n }\n ... on AssociationIndex {\n ...AssociationIndex\n }\n ... on VenueIndex {\n ...VenueIndex\n }\n ... on VenueRentalIndex {\n ...VenueRentalIndex\n }\n ... on ContactIndex {\n ...ContactIndex\n }\n }\n }\n": types.PreviewPageDocument,
@@ -172,14 +168,6 @@ export function graphql(source: string): unknown;
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "): (typeof documents)["\n query allGenericSlugs {\n pages(contentType: \"generic.GenericPage\") {\n id\n urlPath\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n "): (typeof documents)["\n query allNewsSlugs {\n pages(contentType: \"news.NewsPage\") {\n id\n slug\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n "): (typeof documents)["\n query allEventSlugs {\n pages(contentType: \"events.EventPage\") {\n id\n slug\n }\n }\n "];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
File diff suppressed because one or more lines are too long
+8 -1
View File
@@ -8,6 +8,7 @@ import {
} from "date-fns";
import { getClient } from "@/app/client";
import { cachedUntilOsloMidnight } from "@/lib/revalidation";
import { graphql, unmaskFragment } from "@/gql";
import type {
OpeningHoursRangeBlockFragment as OpeningHoursRangeBlock,
@@ -50,7 +51,13 @@ const openingHoursQuery = graphql(`
`);
export async function fetchOpeningHoursSets() {
const { data, error } = await getClient().query(openingHoursQuery, {});
const { data, error } = await getClient().query(
openingHoursQuery,
{},
// The footer renders today's hours on every page, so this TTL doubles as
// the sitewide midnight refresh for futureEvents rollover
cachedUntilOsloMidnight()
);
const sets = (data?.openingHoursSets ?? []) as OpeningHoursSet[];
return sets;
}
+24
View File
@@ -0,0 +1,24 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { secondsUntilOsloMidnight } from "./revalidation.ts";
test("normal day: 12:00 CEST is 12h from midnight", () => {
const now = new Date("2026-07-07T10:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 12 * 3600);
});
test("clamps to 60s just before midnight", () => {
const now = new Date("2026-07-07T21:59:30Z");
assert.equal(secondsUntilOsloMidnight(now), 60);
});
test("DST fall-back day is 25h (2026-10-25)", () => {
const now = new Date("2026-10-24T22:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 25 * 3600);
});
test("DST spring-forward day is 23h (2026-03-29)", () => {
const now = new Date("2026-03-28T23:00:00Z");
assert.equal(secondsUntilOsloMidnight(now), 23 * 3600);
});
+38
View File
@@ -0,0 +1,38 @@
import { addDays, startOfDay } from "date-fns";
import { fromZonedTime, toZonedTime } from "date-fns-tz";
import type { OperationContext } from "@urql/core";
const timeZone = "Europe/Oslo";
// Global cache tag: any CMS content change purges everything
export const CMS_CACHE_TAG = "cms";
const MIN_REVALIDATE_SECONDS = 60;
// Clamped so a request just before midnight never yields revalidate ~0
export function secondsUntilOsloMidnight(now: Date = new Date()): number {
const osloNow = toZonedTime(now, timeZone);
const nextOsloMidnight = startOfDay(addDays(osloNow, 1));
const instant = fromZonedTime(nextOsloMidnight, timeZone);
return Math.max(
Math.ceil((instant.getTime() - now.getTime()) / 1000),
MIN_REVALIDATE_SECONDS
);
}
// Per-operation contexts replace (not merge with) the client-default
// fetchOptions, so both contexts below are self-contained
// For per-user (search) and token-scoped (preview) queries
export const uncached: Partial<OperationContext> = {
fetchOptions: { cache: "no-store" },
};
// For queries whose results depend on "today" (futureEvents, opening hours)
export function cachedUntilOsloMidnight(): Partial<OperationContext> {
return {
fetchOptions: {
next: { revalidate: secondsUntilOsloMidnight(), tags: [CMS_CACHE_TAG] },
},
};
}