Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
STAGING_SECRET=
# Webhook revalidation: set WEBHOOK_SECRET to enable on-demand ISR via Contentful webhooks.
# Configure the Contentful webhook to POST to: https://<your-domain>/api/revalidate?secret=<WEBHOOK_SECRET>
# Setting either WEBHOOK_SECRET or FORCE_STATIC disables all time-based ISR (fully static build).
WEBHOOK_SECRET=
# Set to any non-empty value to disable all ISR with no webhook revalidation (frozen static site).
FORCE_STATIC=
CONTENTFUL_SPACE_ID=
CONTENTFUL_ENVIRONMENT_ID=
CONTENTFUL_ACCESS_TOKEN=
Expand Down
86 changes: 86 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,92 @@ System environment variables and page metadata will also be updated to show it's

Any changes made on Contentful will be reflected on the staging server **every 30 seconds**.

## Contentful Webhook Setup

This guide explains how to configure a Contentful webhook to trigger on-demand ISR revalidation for the session website.

### Prerequisites

Set `WEBHOOK_SECRET` in your environment. This activates webhook mode, which also disables time-based ISR — the site becomes fully static and only revalidates when the webhook fires.

```env
WEBHOOK_SECRET=your-secret-value-here
```

### Contentful Configuration

1. In Contentful, go to **Settings → Webhooks → Add Webhook**.

2. **Name**: `Session Website Revalidation` (or any descriptive name)

3. **URL**: `https://<your-domain>/api/revalidate?secret=<WEBHOOK_SECRET>`

Replace `<your-domain>` with your production domain and `<WEBHOOK_SECRET>` with the value set in your environment.

4. **Triggers**: Select the following events under **Entries**:
- `Publish`
- `Unpublish`
- `Delete`

Deselect all others (Save, Auto save, Create, Archive, Unarchive). The handler ignores other events, but limiting triggers avoids unnecessary webhook calls.

5. **Content type filter** *(optional but recommended)*: Restrict to the content types the site uses:
- `post`
- `page`
- `faq_item`

6. **Headers**: No custom headers are required. The secret is passed as a query parameter. If you prefer header-based auth, you can add a custom header (e.g. `X-Webhook-Secret: <value>`) and update the handler to verify it instead.

7. **Content type** (request body): Leave as the default — `application/vnd.contentful.management.v1+json`.

8. Click **Save**.

### What Gets Revalidated

| Content type | Event | What is revalidated |
|-------------|--------------|---------------------------------------------------------------|
| `post` | publish | `/${slug}`, `/blog` (all locales), tag pages for all post tags |
| `post` | unpublish / delete | `/blog` (all locales) — slug not available in tombstone payload |
| `page` | publish | `/${slug}` (all locales) |
| `page` | unpublish / delete | Data cache busted only — slug not available in tombstone payload |
| `faq_item` | any | `/faq` (all locales) |

### Notes on unpublish / delete

Contentful sends a tombstone payload for unpublish and delete events — the `fields` object is absent. The handler can still identify the content type from `sys.contentType.sys.id` and will:

- Bust the relevant Next.js data cache tag so subsequent requests fetch fresh content.
- Revalidate listing pages (blog index, faq) so removed content disappears from lists.
- For posts: the post's own page at `/${slug}` will naturally return 404 on the next visit because it's no longer in Contentful. ISR handles this via `notFound: true` in `getStaticProps`.
- For pages: without a slug, the specific page path cannot be targeted. The stale page will persist until the next visitor triggers a background regeneration (which will then 404 it).

### Verifying the Webhook

After saving, use the **Send test** button in Contentful to send a test request. The handler will return one of:

- `200 { revalidated: true, ... }` — success
- `200 { revalidated: false, reason: "Ignored topic" }` — test event uses a non-revalidation topic, expected
- `401` — secret mismatch, check the URL query parameter
- `503` — `WEBHOOK_SECRET` is not set in the environment

You can also test locally with:

```bash
curl -X POST \
"http://localhost:3000/api/revalidate?secret=your-secret-value-here" \
-H "Content-Type: application/vnd.contentful.management.v1+json" \
-H "X-Contentful-Topic: ContentManagement.Entry.publish" \
-d '{
"sys": {
"contentType": { "sys": { "id": "post" } }
},
"fields": {
"slug": { "en-US": "your-post-slug" }
},
"metadata": { "tags": [] }
}'
```

## License

Distributed under the GNU GPLv3 License. See [LICENSE](LICENSE) for more information.
Expand Down
33 changes: 24 additions & 9 deletions constants/cms.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import isLive from '@/utils/environment';

/**
* When either WEBHOOK_SECRET or FORCE_STATIC is set the site operates in fully-static
* mode: getStaticProps returns revalidate: false on every page and Next.js will never
* re-fetch content on its own schedule.
*
* WEBHOOK_SECRET additionally enables the /api/revalidate endpoint so Contentful can
* trigger on-demand revalidation when content is published.
*
* Configure the Contentful webhook to POST to:
* https://<your-domain>/api/revalidate?secret=<WEBHOOK_SECRET>
*/
export const IS_STATIC_MODE =
typeof process !== 'undefined' &&
!!(process.env.FORCE_STATIC || process.env.WEBHOOK_SECRET);

const CMS = {
BLOG_RESULTS_PER_PAGE: 13,
BLOG_RESULTS_PER_PAGE_TAGGED: 12,
// Next.js will try and re-build the page when a request comes in
// every 1 hour for production and every 30 seconds for staging
CONTENT_REVALIDATE_RATE: isLive() ? 3600 : 30,
// For older blog posts (>30 days), revalidate once per day
CONTENT_REVALIDATE_RATE_OLD: isLive() ? 86400 : 30,
// every 6 hours for production and every 30 seconds for staging
CONTENT_REVALIDATE_RATE: isLive() ? 21600 : 30,
// For older blog posts (>30 days), revalidate once per week
CONTENT_REVALIDATE_RATE_OLD: isLive() ? 604800 : 30,
// Age threshold (in days) to consider a post "old"
OLD_POST_AGE_DAYS: 30,
// So we dont get rate limited by the GitHub API
Expand All @@ -16,13 +31,13 @@ const CMS = {

/**
* Calculate the appropriate revalidation time for a blog post based on its age.
*
*
* Strategy:
* - Posts newer than 30 days: revalidate every 1 hour (more frequent updates expected)
* - Posts older than 30 days: revalidate once per day (content is stable)
*
* - Posts newer than 30 days: revalidate every 6 hours (recently published content)
* - Posts older than 30 days: revalidate once per week (stable content)
*
* This reduces API calls for older content that rarely changes.
*
*
* @param publishedDateISO - ISO date string of when the post was published
* @returns Revalidation time in seconds
*/
Expand Down
4 changes: 2 additions & 2 deletions constants/index.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import BANNER from './banner';
import CMS, { getRevalidationTime } from './cms';
import CMS, { getRevalidationTime, IS_STATIC_MODE } from './cms';
import LINKS from './links';
import METADATA from './metadata';
import NAVIGATION from './navigation';
import SIGNUPS from './signups';
import TOS from './tos';
import UI from './ui';

export { BANNER, CMS, getRevalidationTime, LINKS, METADATA, NAVIGATION, SIGNUPS, TOS, UI };
export { BANNER, CMS, getRevalidationTime, IS_STATIC_MODE, LINKS, METADATA, NAVIGATION, SIGNUPS, TOS, UI };
1 change: 1 addition & 0 deletions lib/app_localization
Submodule app_localization added at 8ab418
2 changes: 2 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,8 @@ const nextConfig = {
MAILERLITE_API_KEY: process.env.MAILERLITE_API_KEY,
MAILERLITE_GROUP_ID: process.env.MAILERLITE_GROUP_ID,
NEXT_PUBLIC_TRANSLATION_MODE: process.env.NEXT_PUBLIC_TRANSLATION_MODE,
WEBHOOK_SECRET: process.env.WEBHOOK_SECRET,
FORCE_STATIC: process.env.FORCE_STATIC,
},

async headers() {
Expand Down
30 changes: 14 additions & 16 deletions pages/[slug].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { GetStaticPaths, GetStaticPropsContext } from 'next';
import type { ReactElement } from 'react';
import BlogPost from '@/components/BlogPost';
import RichPage from '@/components/RichPage';
import { CMS, getRevalidationTime } from '@/constants';
import { CMS, getRevalidationTime, IS_STATIC_MODE } from '@/constants';
import { fetchBlogEntries, fetchEntryBySlug, generateLinkMeta } from '@/services/cms';
import { hasRedirection } from '@/services/redirect';
import { type IPage, type IPost, isPost } from '@/types/cms';
Expand Down Expand Up @@ -38,7 +38,7 @@ export async function getStaticProps(context: GetStaticPropsContext) {
return {
props: { messages },
redirect: redirect,
revalidate: CMS.CONTENT_REVALIDATE_RATE,
revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE,
};
}

Expand All @@ -60,20 +60,19 @@ export async function getStaticProps(context: GetStaticPropsContext) {
.slice(0, 6);
}

// Calculate revalidation time based on content age
const revalidate = isPost(content)
? getRevalidationTime(content.publishedDateISO)
: CMS.CONTENT_REVALIDATE_RATE;
// Calculate revalidation time based on content age (ignored in static mode)
const revalidate = IS_STATIC_MODE
? false
: isPost(content)
? getRevalidationTime(content.publishedDateISO)
: CMS.CONTENT_REVALIDATE_RATE;

// Log revalidation time in dev builds
if (process.env.NODE_ENV === 'development') {
const contentType = isPost(content) ? 'Post' : 'Page';
const ageInfo = isPost(content)
? ` (published: ${content.publishedDate})`
: '';
console.log(
`[Revalidate] ${contentType} "/${slug}"${ageInfo} - ${revalidate}s (${Math.round(revalidate / 60)}min)`
);
const ageInfo = isPost(content) ? ` (published: ${content.publishedDate})` : '';
const revalidateInfo = IS_STATIC_MODE ? 'static (webhook-only)' : `${revalidate}s (${Math.round((revalidate as number) / 60)}min)`;
console.log(`[Revalidate] ${contentType} "/${slug}"${ageInfo} - ${revalidateInfo}`);
}

return {
Expand All @@ -85,17 +84,16 @@ export async function getStaticProps(context: GetStaticPropsContext) {
if (process.env.NODE_ENV === 'development') {
console.warn(`[404] Page not found: "/${slug}"`);
}

// For non-dev, only log actual errors (not 404s from regular navigation)
if (err instanceof Error && !err.message.includes('Failed to fetch entry')) {
console.error(err);
}

return {
props: { messages },
notFound: true,
// Use longer revalidation for 404 pages to reduce unnecessary rebuilds
revalidate: CMS.CONTENT_REVALIDATE_RATE_OLD,
revalidate: IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE_OLD,
};
}
}
Expand Down
Loading
Loading