Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
6 changes: 2 additions & 4 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 Expand Up @@ -311,10 +313,6 @@ const nextConfig = {
source: '/windows',
destination: '/api/download/windows',
},
{
source: '/blog/:slug',
destination: '/:slug',
},
];
},

Expand Down
95 changes: 26 additions & 69 deletions pages/[slug].tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,24 @@
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 { fetchBlogEntries, fetchEntryBySlug, generateLinkMeta } from '@/services/cms';
import { CMS, IS_STATIC_MODE } from '@/constants';
import { fetchEntryBySlug, generateLinkMeta } from '@/services/cms';
import { hasRedirection } from '@/services/redirect';
import { type IPage, type IPost, isPost } from '@/types/cms';
import { type IPage, isPost } from '@/types/cms';

interface Props {
content: IPage | IPost;
otherPosts?: IPost[];
content: IPage;
messages: any;
}

export default function Page(props: Props): ReactElement {
const { content } = props;
if (isPost(content)) {
return <BlogPost post={content} otherPosts={props.otherPosts} />;
} else {
return <RichPage page={content} />;
}
return <RichPage page={props.content} />;
}

export async function getStaticProps(context: GetStaticPropsContext) {
const locale = context.locale || 'en';

console.log(
`Building: Page%c${context.params?.slug ? ` /${context.params?.slug}` : ''}`,
'color: purple;'
);
console.log(`[Build] Page: /${context.params?.slug ?? ''}`);
const slug = String(context.params?.slug);

const messages = (await import(`../locales/${locale}.json`)).default;
Expand All @@ -38,94 +28,61 @@ 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,
};
}

try {
const content: IPage | IPost = await fetchEntryBySlug(slug);

// embedded links in content body need metadata for preview
content.body = await generateLinkMeta(content.body);

const props: Props = { content, messages };
const content = await fetchEntryBySlug(slug);

// Posts have moved to /blog/[slug] — redirect permanently so existing links are preserved
if (isPost(content)) {
// we want 6 posts excluding the current one if it's found
const { entries: posts } = await fetchBlogEntries(7);
props.otherPosts = posts
.filter((post) => {
return content.slug !== post.slug;
})
.slice(0, 6);
return {
redirect: { destination: `/blog/${slug}`, permanent: true },
};
}

// Calculate revalidation time based on content age
const revalidate = isPost(content)
? getRevalidationTime(content.publishedDateISO)
: CMS.CONTENT_REVALIDATE_RATE;
// embedded links in content body need metadata for preview
content.body = await generateLinkMeta(content.body);

// 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 revalidate = IS_STATIC_MODE ? false : CMS.CONTENT_REVALIDATE_RATE;
console.log(`[Build] Done: /${slug} (page, revalidate=${IS_STATIC_MODE ? 'static/webhook' : `${revalidate}s`})`);

return {
props,
props: { content, messages },
revalidate,
};
} catch (err) {
// Log 404s in dev builds to help identify problematic access patterns
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,
};
}
}

export const getStaticPaths: GetStaticPaths = async ({ locales }) => {
const { fetchPages, fetchAllBlogEntries } = await import('@/services/cms');
const { fetchPages } = await import('@/services/cms');
const { entries: pages } = await fetchPages();
const posts = await fetchAllBlogEntries();

// Generate paths for pages (all locales) and posts (en only)
const pagePaths = pages.flatMap((page) =>
// Only pre-build CMS pages. Post slugs are handled by pages/blog/[slug].tsx.
// Any /{post-slug} hit falls through to getStaticProps which issues a permanent redirect.
const paths = pages.flatMap((page) =>
(locales || ['en']).map((locale) => ({
params: {
slug: page.slug,
},
params: { slug: page.slug },
locale,
}))
);

const postPaths = posts.map((post) => ({
params: {
slug: post.slug,
},
locale: 'en',
}));

const paths = [...pagePaths, ...postPaths];

return {
paths,
fallback: 'blocking',
};
return { paths, fallback: 'blocking' };
};
Loading