From 87283cd492cae4b4f76ad22d7cb179288f43e6b7 Mon Sep 17 00:00:00 2001 From: MenKNas Date: Wed, 6 May 2026 12:21:45 +0300 Subject: [PATCH 1/4] [nextjs][nextjs-app-router-osr] Add new app router template for OSR support via tag revalidation --- .../nextjs-app-router-osr/args.ts | 4 + .../nextjs-app-router-osr/index.ts | 16 ++ .../.env.container.example | 31 ++++ .../nextjs-app-router-osr/.env.remote.example | 55 ++++++ .../nextjs-app-router-osr/.gitattributes | 11 ++ .../.sitecore/component-map.client.ts | 15 ++ .../.sitecore/component-map.ts | 14 ++ .../.sitecore/import-map.client.ts | 12 ++ .../.sitecore/import-map.server.ts | 60 +++++++ .../templates/nextjs-app-router-osr/README.md | 7 + .../docs/tag-based-revalidation.md | 16 ++ .../nextjs-app-router-osr/eslint.config.mjs | 21 +++ .../templates/nextjs-app-router-osr/gitignore | 31 ++++ .../nextjs-app-router-osr/next-env.d.ts | 6 + .../nextjs-app-router-osr/next.config.ts | 46 +++++ .../nextjs-app-router-osr/package.json | 56 ++++++ .../nextjs-app-router-osr/postcss.config.mjs | 5 + .../sitecore.cli.config.ts | 26 +++ .../nextjs-app-router-osr/sitecore.config.ts | 7 + .../sitecore.config.ts.example | 40 +++++ .../nextjs-app-router-osr/src/Bootstrap.tsx | 52 ++++++ .../nextjs-app-router-osr/src/Layout.tsx | 80 +++++++++ .../nextjs-app-router-osr/src/Providers.tsx | 18 ++ .../nextjs-app-router-osr/src/Scripts.tsx | 17 ++ .../[site]/[locale]/[[...path]]/layout.tsx | 19 ++ .../[site]/[locale]/[[...path]]/not-found.tsx | 47 +++++ .../app/[site]/[locale]/[[...path]]/page.tsx | 87 +++++++++ .../src/app/[site]/layout.tsx | 20 +++ .../src/app/api/editing/config/route.ts | 15 ++ .../src/app/api/editing/render/route.ts | 15 ++ .../src/app/api/revalidate/route.ts | 3 + .../src/app/api/revalidate/webhook/route.ts | 31 ++++ .../src/app/api/robots/route.ts | 16 ++ .../src/app/api/sitemap/route.ts | 15 ++ .../nextjs-app-router-osr/src/app/favicon.ico | Bin 0 -> 15086 bytes .../src/app/global-error.tsx | 51 ++++++ .../nextjs-app-router-osr/src/app/globals.css | 1 + .../nextjs-app-router-osr/src/app/layout.tsx | 9 + .../src/app/not-found.tsx | 46 +++++ .../nextjs-app-router-osr/src/assets/main.css | 1 + .../src/byoc/index.client.tsx | 20 +++ .../src/byoc/index.hybrid.ts | 10 ++ .../nextjs-app-router-osr/src/byoc/index.tsx | 39 +++++ .../components/content-sdk/CdpPageView.tsx | 59 +++++++ .../components/content-sdk/SitecoreStyles.tsx | 32 ++++ .../nextjs-app-router-osr/src/i18n/request.ts | 27 +++ .../nextjs-app-router-osr/src/i18n/routing.ts | 15 ++ .../src/lib/cache/get-sitecore-dictionary.ts | 20 +++ .../src/lib/cache/get-sitecore-page.ts | 33 ++++ .../src/lib/component-props/index.ts | 33 ++++ .../src/lib/image-remote-patterns.ts | 17 ++ .../src/lib/sitecore-client.ts | 8 + .../nextjs-app-router-osr/src/proxy.ts | 110 ++++++++++++ .../nextjs-app-router-osr/tsconfig.json | 48 +++++ .../nextjs-app-router/src/assets/main.css | 1 + .../src/cache/sitecore-cache-tags.test.ts | 123 +++++++++++++ .../nextjs/src/cache/sitecore-cache-tags.ts | 165 ++++++++++++++++++ ...sitecore-edge-webhook-revalidation.test.ts | 90 ++++++++++ .../sitecore-edge-webhook-revalidation.ts | 103 +++++++++++ .../cache/sitecore-page-cache-tags.test.ts | 51 ++++++ .../src/cache/sitecore-page-cache-tags.ts | 71 ++++++++ packages/nextjs/src/index.ts | 30 ++++ ...e-webhook-revalidate-route-handler.test.ts | 110 ++++++++++++ .../edge-webhook-revalidate-route-handler.ts | 103 +++++++++++ packages/nextjs/src/route-handler/index.ts | 8 + .../revalidate-route-handler.test.ts | 157 +++++++++++++++++ .../route-handler/revalidate-route-handler.ts | 97 ++++++++++ scripts/samples.json | 7 + 68 files changed, 2609 insertions(+) create mode 100644 packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/args.ts create mode 100644 packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/index.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.container.example create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.remote.example create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.gitattributes create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.client.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.client.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.server.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/README.md create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/docs/tag-based-revalidation.md create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/eslint.config.mjs create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/gitignore create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next-env.d.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next.config.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/package.json create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/postcss.config.mjs create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.cli.config.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts.example create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Bootstrap.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Layout.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Providers.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Scripts.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/layout.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/not-found.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/page.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/layout.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/config/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/render/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/webhook/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/robots/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/sitemap/route.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/favicon.ico create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/global-error.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/globals.css create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/layout.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/not-found.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/assets/main.css create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.client.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.hybrid.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/CdpPageView.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/SitecoreStyles.tsx create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/request.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/routing.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-dictionary.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-page.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/component-props/index.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/image-remote-patterns.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/sitecore-client.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/proxy.ts create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/tsconfig.json create mode 100644 packages/create-content-sdk-app/src/templates/nextjs-app-router/src/assets/main.css create mode 100644 packages/nextjs/src/cache/sitecore-cache-tags.test.ts create mode 100644 packages/nextjs/src/cache/sitecore-cache-tags.ts create mode 100644 packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.test.ts create mode 100644 packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts create mode 100644 packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts create mode 100644 packages/nextjs/src/cache/sitecore-page-cache-tags.ts create mode 100644 packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.test.ts create mode 100644 packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts create mode 100644 packages/nextjs/src/route-handler/revalidate-route-handler.test.ts create mode 100644 packages/nextjs/src/route-handler/revalidate-route-handler.ts diff --git a/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/args.ts b/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/args.ts new file mode 100644 index 0000000000..e7672ffa79 --- /dev/null +++ b/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/args.ts @@ -0,0 +1,4 @@ +import { BaseAppArgs } from '../../common'; +import { NextjsAppRouterAnswer } from '../nextjs-app-router/prompts'; + +export type NextjsAppRouterOsrArgs = BaseAppArgs & Partial; diff --git a/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/index.ts b/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/index.ts new file mode 100644 index 0000000000..cfe4f658a6 --- /dev/null +++ b/packages/create-content-sdk-app/src/initializers/nextjs-app-router-osr/index.ts @@ -0,0 +1,16 @@ +import path from 'path'; +import inquirer from 'inquirer'; +import { prompts, NextjsAppRouterAnswer } from '../nextjs-app-router/prompts'; +import { Initializer, transform } from '../../common'; +import { NextjsAppRouterOsrArgs } from './args'; + +export default class NextjsAppRouterOsrInitializer implements Initializer { + async init(args: NextjsAppRouterOsrArgs) { + const answers = await inquirer.prompt(prompts, args); + const templatePath = path.resolve(__dirname, '../../templates/nextjs-app-router-osr'); + + await transform(templatePath, { ...args, ...answers }); + + return {}; + } +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.container.example b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.container.example new file mode 100644 index 0000000000..a8441dd394 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.container.example @@ -0,0 +1,31 @@ +# This file should be copied to .env.local and modified with your environment variables to connect to your Sitecore container instance. + +# To secure the Sitecore editor endpoint exposed by your Next.js app +# (`/api/editing/render` by default), a secret token is used. This (client-side) +SITECORE_EDITING_SECRET= + +# Secret token for protected cache tag revalidation endpoint (`/api/revalidate`). +# Send this value in the `x-revalidate-secret` header. +SITECORE_REVALIDATE_SECRET= + +# Your Sitecore site name. +# The value of the variable represents the default/configured site. +NEXT_PUBLIC_DEFAULT_SITE_NAME= + +# Your default app language. +NEXT_PUBLIC_DEFAULT_LANGUAGE= + +# Your Sitecore API key is needed to build the app. +NEXT_PUBLIC_SITECORE_API_KEY= + +# Your Sitecore API hostname is needed to build the app. +NEXT_PUBLIC_SITECORE_API_HOST= + +# Sitecore Content SDK npm packages utilize the debug module for debug logging. +# https://www.npmjs.com/package/debug +# Set the DEBUG environment variable to 'content-sdk:*' to see all logs: +#DEBUG=content-sdk:* +# Or be selective and show for example only layout service logs: +#DEBUG=content-sdk:layout +# Or everything BUT layout service logs: +#DEBUG=content-sdk:*,-content-sdk:layout diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.remote.example b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.remote.example new file mode 100644 index 0000000000..c7e2695770 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.env.remote.example @@ -0,0 +1,55 @@ +# This file should be copied to .env.local and modified with your environment variables to connect to your remote Sitecore instance. + +# To secure the Sitecore editor endpoint exposed by your Next.js app +# (`/api/editing/render` by default), a secret token is used. This (client-side) +SITECORE_EDITING_SECRET= + +# Secret token for protected cache tag revalidation endpoint (`/api/revalidate`). +# Send this value in the `x-revalidate-secret` header. +SITECORE_REVALIDATE_SECRET= + +# Your Sitecore site name. +# The value of the variable represents the default/configured site. +NEXT_PUBLIC_DEFAULT_SITE_NAME= + +# Your default app language. +NEXT_PUBLIC_DEFAULT_LANGUAGE= + +# Your unified Sitecore Edge Context Id for server-side use. +# This will be used over any Sitecore Preview / Delivery Edge variables (above). +SITECORE_EDGE_CONTEXT_ID= + +# Your Sitecore Edge Context Id for client-side use. +# Will be used as a fallback if separate SITECORE_EDGE_CONTEXT_ID value is not provided +NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID= + +# Optional: custom Sitecore Edge Platform hostname (hostname or full URL). +NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME= + +# Optional: custom Experience Edge hostname for media URL rewriting (e.g. staging). +SITECORE_EXPERIENCE_EDGE_HOSTNAME= + +# An optional Sitecore Personalize scope identifier. +# This can be used to isolate personalization data when multiple XM Cloud Environments share a Personalize tenant. +# This should match the PAGES_PERSONALIZE_SCOPE environment variable for your connected XM Cloud Environment. +NEXT_PUBLIC_PERSONALIZE_SCOPE= + +# Timeout (ms) for Sitecore CDP requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_CDP_TIMEOUT= + +# Timeout (ms) for Sitecore Experience Edge requests to respond within. Default is 400. +PERSONALIZE_MIDDLEWARE_EDGE_TIMEOUT= + +# Sitecore Content SDK npm packages utilize the debug module for debug logging. +# https://www.npmjs.com/package/debug +# Set the DEBUG environment variable to 'content-sdk:*' to see all logs: +#DEBUG=content-sdk:* +# Or be selective and show for example only layout service logs: +#DEBUG=content-sdk:layout +# Or everything BUT layout service logs: +#DEBUG=content-sdk:*,-content-sdk:layout + +# Client ID, Secret and Rendering Host Name used for Design Library functionality +SITECORE_AUTH_CLIENT_ID= +SITECORE_AUTH_CLIENT_SECRET= +SITECORE_RENDERINGHOST_NAME= diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.gitattributes b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.gitattributes new file mode 100644 index 0000000000..0f0e875a71 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.gitattributes @@ -0,0 +1,11 @@ +# Line endings for this repository +# See: https://help.github.com/en/articles/configuring-git-to-handle-line-endings +# This should line up with the expectations from .eslintrc + +# Set the default behavior, in case people don't have core.autocrlf set. +* text=crlf + +# Declare files that will always have CRLF line endings on checkout. +*.ts text eol=crlf +*.tsx text eol=crlf +*.js text eol=crlf diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.client.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.client.ts new file mode 100644 index 0000000000..b02598ada3 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.client.ts @@ -0,0 +1,15 @@ +// Client-safe component map for App Router +import { + BYOCClientWrapper, + NextjsContentSdkComponent, + FEaaSClientWrapper, +} from '@sitecore-content-sdk/nextjs'; +import { Form } from '@sitecore-content-sdk/nextjs'; + +export const componentMap = new Map([ + ['BYOCWrapper', BYOCClientWrapper], + ['FEaaSWrapper', FEaaSClientWrapper], + ['Form', Form], +]); + +export default componentMap; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.ts new file mode 100644 index 0000000000..b882b6fc89 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/component-map.ts @@ -0,0 +1,14 @@ +// Below are built-in components that are available in the app, it's recommended to keep them as is +import { BYOCWrapper, NextjsContentSdkComponent, FEaaSWrapper } from '@sitecore-content-sdk/nextjs'; +import { Form } from '@sitecore-content-sdk/nextjs'; +// end of built-in components + +// Components must be registered within the map to match the string key with component name in Sitecore +export const componentMap = new Map([ + ['BYOCWrapper', BYOCWrapper], + ['FEaaSWrapper', FEaaSWrapper], + ['Form', { ...Form, componentType: 'client' }], +]); + +export default componentMap; + diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.client.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.client.ts new file mode 100644 index 0000000000..3ffb91464d --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.client.ts @@ -0,0 +1,12 @@ +// This file is auto-generated by the Sitecore Content SDK. +// Below are built-in Content SDK imports neccessary for the import map +import { + combineImportEntries, + defaultImportEntries, + ImportEntry, +} from '@sitecore-content-sdk/nextjs/codegen'; +// end of built-in imports + +const importMap: ImportEntry[] = []; + +export default combineImportEntries(defaultImportEntries, importMap); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.server.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.server.ts new file mode 100644 index 0000000000..6e7d259478 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/.sitecore/import-map.server.ts @@ -0,0 +1,60 @@ +// This file is auto-generated by the Sitecore Content SDK. +// Below are built-in Content SDK imports neccessary for the import map +import { combineImportEntries, defaultImportEntries } from '@sitecore-content-sdk/nextjs/codegen'; +// end of built-in imports + +import { + Link, + Text, + RichText, + NextImage, + Placeholder as Placeholder_8a80e63291fea86e0744df19113dc44bec187216, + AppPlaceholder, + CdpHelper, +} from '@sitecore-content-sdk/nextjs'; +import { Suspense } from 'react'; +import React from 'react'; +import { componentMap } from '.sitecore/component-map'; +import client from 'src/lib/sitecore-client'; +import { pageView } from '@sitecore-content-sdk/events'; +import config from 'sitecore.config'; + +const importMapServer = [ + { + module: '@sitecore-content-sdk/nextjs', + exports: [ + { name: 'Link', value: Link }, + { name: 'Text', value: Text }, + { name: 'RichText', value: RichText }, + { name: 'NextImage', value: NextImage }, + { name: 'Placeholder', value: Placeholder_8a80e63291fea86e0744df19113dc44bec187216 }, + { name: 'AppPlaceholder', value: AppPlaceholder }, + { name: 'CdpHelper', value: CdpHelper }, + ], + }, + { + module: 'react', + exports: [ + { name: 'Suspense', value: Suspense }, + { name: 'default', value: React }, + ], + }, + { + module: '.sitecore/component-map', + exports: [{ name: 'componentMap', value: componentMap }], + }, + { + module: 'src/lib/sitecore-client', + exports: [{ name: 'default', value: client }], + }, + { + module: '@sitecore-content-sdk/events', + exports: [{ name: 'pageView', value: pageView }], + }, + { + module: 'sitecore.config', + exports: [{ name: 'default', value: config }], + }, +]; + +export default combineImportEntries(defaultImportEntries, importMapServer); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/README.md b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/README.md new file mode 100644 index 0000000000..6e83750a4f --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/README.md @@ -0,0 +1,7 @@ +# Sitecore Content SDK Next.js App Router — On-demand revalidation (OSR) + +This starter matches the default **nextjs-app-router** template, with **tag-based on-demand revalidation** enabled: Next.js Cache Components (`cacheComponents`), `getSitecorePage` / `getSitecoreDictionary` helpers that apply Sitecore cache tags, and **`/api/revalidate`** plus **`/api/revalidate/webhook`** route handlers. From the app root you can call those URLs using standard HTTP tooling (see `docs/tag-based-revalidation.md` and the monorepo **`docs/tag-based-revalidation.md`** for the full walkthrough). + +Use the **`nextjs-app-router`** template if you do not need OSR wiring. + +[SitecoreAI Content SDK Documentation](https://doc.sitecore.com/sai/en/developers/content-sdk/sitecore-content-sdk-for-sitecoreai.html) diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/docs/tag-based-revalidation.md b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/docs/tag-based-revalidation.md new file mode 100644 index 0000000000..cec39a241c --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/docs/tag-based-revalidation.md @@ -0,0 +1,16 @@ +# Tag-based OSR in this app + +Step-by-step guidance and customization live in the Content SDK repository: + +**[docs/tag-based-revalidation.md](https://github.com/Sitecore/content-sdk/blob/dev/docs/tag-based-revalidation.md)** (source path in the monorepo: `docs/tag-based-revalidation.md`). + +## Quick map + +| Piece | Path | +|-------|------| +| Cached page + tags | `src/lib/cache/get-sitecore-page.ts` | +| Cached dictionary + tag | `src/lib/cache/get-sitecore-dictionary.ts` | +| Manual `POST` | `src/app/api/revalidate/route.ts` | +| Webhook `POST` | `src/app/api/revalidate/webhook/route.ts` | + +Set **`SITECORE_REVALIDATE_SECRET`** and call the revalidate routes with standard HTTP tooling from the app root (see the main doc). diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/eslint.config.mjs b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/eslint.config.mjs new file mode 100644 index 0000000000..5ef0b862bf --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/eslint.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig, globalIgnores } from "eslint/config"; +import nextVitals from "eslint-config-next/core-web-vitals"; +import nextTs from "eslint-config-next/typescript"; + +export default defineConfig([ + ...nextVitals, + ...nextTs, + { + rules: { + // Don't force alt for (sourced from Sitecore media) + "jsx-a11y/alt-text": "off", + }, + }, + globalIgnores([ + "node_modules/**", + ".next/**", + "out/**", + "build/**", + "next-env.d.ts", + ]), +]); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/gitignore b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/gitignore new file mode 100644 index 0000000000..444c3e65e8 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/ignore-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next*/ +/out/ + +# misc +.DS_Store + +# local env files +.env.local +.env.*.local +.env + +# Log files +*.log* + +# vercel +.vercel + +# sitecore temp files +.sitecore/* +# except for component-map +!.sitecore/component-map.ts +!.sitecore/component-map.client.ts +!.sitecore/import-map.ts +!.sitecore/import-map.server.ts +!.sitecore/import-map.client.ts diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next-env.d.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next-env.d.ts new file mode 100644 index 0000000000..830fb594ca --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next.config.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next.config.ts new file mode 100644 index 0000000000..88efb70e75 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/next.config.ts @@ -0,0 +1,46 @@ +import type { NextConfig } from 'next'; +import createNextIntlPlugin from 'next-intl/plugin'; +import { imageRemotePatterns } from './src/lib/image-remote-patterns'; + +const allowedDevOrigins = (process.env.NEXT_ALLOWED_DEV_ORIGINS ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + +const nextConfig: NextConfig = { + // Enable Cache Components (`use cache`, `cacheTag`) in Next.js App Router. + cacheComponents: true, + ...(allowedDevOrigins.length > 0 ? { allowedDevOrigins } : {}), + + // Enable Turbopack file system caching for faster dev startup (beta) + // See: https://nextjs.org/docs/app/api-reference/config/next-config-js/turbopack + experimental: { + turbopackFileSystemCacheForDev: true, + }, + + // use this configuration to ensure that only images from the whitelisted domains + // can be served from the Next.js Image Optimization API + // see https://nextjs.org/docs/app/api-reference/components/image#remotepatterns + images: { + remotePatterns: imageRemotePatterns, + }, + // use this configuration to serve the sitemap.xml and robots.txt files from the API route handlers + rewrites: async () => { + return [ + { + source: '/sitemap:id([\\w-]{0,}).xml', + destination: '/api/sitemap', + locale: false, + }, + { + source: '/robots.txt', + destination: '/api/robots', + locale: false, + }, + ]; + }, +}; + +const withNextIntl = createNextIntlPlugin(); + +export default withNextIntl(nextConfig); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/package.json b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/package.json new file mode 100644 index 0000000000..d93de57eaf --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/package.json @@ -0,0 +1,56 @@ +{ + "name": "content-sdk-nextjs-app-router-osr", + "description": "Application utilizing Content SDK and Next.js", + "version": "0.1.0", + "private": true, + "author": { + "name": "Sitecore Corporation", + "url": "https://doc.sitecore.com/xmc/en/developers/content-sdk/index.html" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/sitecore/content-sdk.git" + }, + "bugs": { + "url": "https://github.com/sitecore/content-sdk/issues" + }, + "license": "Apache-2.0", + "scripts": { + "build": "cross-env NODE_ENV=production run-s sitecore-tools:generate-map sitecore-tools:build next:build", + "build:corp-tls": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 NODE_ENV=production run-s sitecore-tools:generate-map sitecore-tools:build next:build", + "lint": "eslint ./src/**/*.tsx ./src/**/*.ts", + "next:build": "next build", + "next:dev": "cross-env NODE_OPTIONS='--inspect' next dev", + "next:start": "next start", + "sitecore-tools:generate-map": "sitecore-tools project component generate-map", + "sitecore-tools:generate-map:watch": "sitecore-tools project component generate-map --watch", + "sitecore-tools:build": "sitecore-tools project build", + "dev": "cross-env NODE_ENV=development run-s sitecore-tools:generate-map sitecore-tools:build && run-p next:dev sitecore-tools:generate-map:watch", + "start": "cross-env NODE_ENV=production run-s build next:start" + }, + "dependencies": { + "@sitecore-content-sdk/nextjs": "<%- version %>", + "@sitecore-content-sdk/analytics-core": "<%- version %>", + "@sitecore-content-sdk/personalize": "<%- version %>", + "@sitecore-content-sdk/events": "<%- version %>", + "@sitecore-feaas/clientside": "^0.6.0", + "@sitecore/components": "~2.1.0", + "next": "^16.2.0", + "react": "^19.2.1", + "react-dom": "^19.2.1", + "next-intl": "^4.3.5" + }, + "devDependencies": { + "@sitecore-content-sdk/cli": "<%- version %>", + "@tailwindcss/postcss": "^4", + "@types/node": "^24.10.4", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "cross-env": "^10.0.0", + "eslint": "^9.33.0", + "eslint-config-next": "16.2.2", + "npm-run-all2": "^8.0.4", + "tailwindcss": "^4", + "typescript": "~5.8.3" + } +} \ No newline at end of file diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/postcss.config.mjs b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/postcss.config.mjs new file mode 100644 index 0000000000..c7bcb4b1ee --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/postcss.config.mjs @@ -0,0 +1,5 @@ +const config = { + plugins: ["@tailwindcss/postcss"], +}; + +export default config; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.cli.config.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.cli.config.ts new file mode 100644 index 0000000000..4f756541f4 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.cli.config.ts @@ -0,0 +1,26 @@ +import { defineCliConfig } from '@sitecore-content-sdk/nextjs/config-cli'; +import { + generateSites, + generateMetadata, + extractFiles, + writeImportMap, +} from '@sitecore-content-sdk/nextjs/tools'; +import scConfig from './sitecore.config'; + +export default defineCliConfig({ + config: scConfig, + build: { + commands: [ + generateMetadata(), + generateSites(), + extractFiles(), + writeImportMap({ + paths: ['src/components'], + }), + ], + }, + componentMap: { + paths: ['src/components'], + exclude: ['src/components/content-sdk/*'], + }, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts new file mode 100644 index 0000000000..24de77be3d --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from '@sitecore-content-sdk/nextjs/config'; +/** + * @type {import('@sitecore-content-sdk/nextjs/config').SitecoreConfig} + * See the documentation for `defineConfig`: + * https://doc.sitecore.com/xmc/en/developers/content-sdk/the-sitecore-configuration-file.html + */ +export default defineConfig({}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts.example b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts.example new file mode 100644 index 0000000000..befae0d08a --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts.example @@ -0,0 +1,40 @@ +import { defineConfig } from '@sitecore-content-sdk/nextjs/config'; +/** + * @type {import('@sitecore-content-sdk/nextjs/config').SitecoreConfig} + * See the documentation for `defineConfig`: + * https://doc.sitecore.com/xmc/en/developers/content-sdk/the-sitecore-configuration-file.html + */ +export default defineConfig({ + api: { + edge: { + contextId: + process.env.SITECORE_EDGE_CONTEXT_ID || + process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID || + '', + clientContextId: process.env.NEXT_PUBLIC_SITECORE_EDGE_CONTEXT_ID, + edgeUrl: + process.env.NEXT_PUBLIC_SITECORE_EDGE_PLATFORM_HOSTNAME || + process.env.SITECORE_EDGE_PLATFORM_HOSTNAME, + }, + local: { + apiKey: process.env.NEXT_PUBLIC_SITECORE_API_KEY || '', + apiHost: process.env.NEXT_PUBLIC_SITECORE_API_HOST || '', + }, + }, + defaultSite: process.env.NEXT_PUBLIC_DEFAULT_SITE_NAME, + defaultLanguage: process.env.NEXT_PUBLIC_DEFAULT_LANGUAGE || 'en', + editingSecret: process.env.SITECORE_EDITING_SECRET, + redirects: { + enabled: true, + locales: ['en'], + }, + multisite: { + enabled: true, + useCookieResolution: () => process.env.VERCEL_ENV === 'preview', + }, + personalize: { + scope: process.env.NEXT_PUBLIC_PERSONALIZE_SCOPE, + edgeTimeout: parseInt(process.env.PERSONALIZE_PROXY_EDGE_TIMEOUT!, 10), + cdpTimeout: parseInt(process.env.PERSONALIZE_PROXY_EDGE_TIMEOUT!, 10), + }, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Bootstrap.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Bootstrap.tsx new file mode 100644 index 0000000000..4b30c2c9c1 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Bootstrap.tsx @@ -0,0 +1,52 @@ +'use client'; +import { useEffect, JSX } from 'react'; +import { initContentSdk } from '@sitecore-content-sdk/nextjs'; +import { eventsPlugin } from '@sitecore-content-sdk/events'; +import { analyticsBrowserAdapter, analyticsPlugin } from '@sitecore-content-sdk/analytics-core'; +import config from 'sitecore.config'; + +const Bootstrap = ({ + siteName, + isPreviewMode, +}: { + siteName: string; + isPreviewMode: boolean; +}): JSX.Element | null => { + useEffect(() => { + if (process.env.NODE_ENV === 'development') { + console.debug('Browser Events SDK is not initialized in development environment'); + return; + } + + if (isPreviewMode) { + console.debug('Browser Events SDK is not initialized in edit and preview modes'); + return; + } + + if (config.api.edge?.clientContextId) { + initContentSdk({ + config: { + contextId: config.api.edge.clientContextId, + edgeUrl: config.api.edge.edgeUrl, + siteName: siteName || config.defaultSite, + }, + plugins: [ + analyticsPlugin({ + options: { + enableCookie: true, + cookieDomain: window.location.hostname.replace(/^www\./, ''), + }, + adapter: analyticsBrowserAdapter(), + }), + eventsPlugin(), + ], + }); + } else { + console.error('Client Edge API settings missing from configuration'); + } + }, [siteName, isPreviewMode]); + + return null; +}; + +export default Bootstrap; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Layout.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Layout.tsx new file mode 100644 index 0000000000..bd10a3b590 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Layout.tsx @@ -0,0 +1,80 @@ +import { JSX } from 'react'; +import { AppPlaceholder, DesignLibraryApp, Field, Page } from '@sitecore-content-sdk/nextjs'; +import Scripts from 'src/Scripts'; +import SitecoreStyles from 'components/content-sdk/SitecoreStyles'; +import componentMap from '.sitecore/component-map'; + +interface LayoutProps { + page: Page; +} + +export interface RouteFields { + [key: string]: unknown; + Title?: Field; +} + +const Layout = ({ page }: LayoutProps): JSX.Element => { + const { layout, mode } = page; + const { route } = layout.sitecore; + const mainClassPageEditing = mode.isEditing ? 'editing-mode' : 'prod-mode'; + return ( + <> + + + {/* root placeholder for the app, which we add components to using route data */} +
+ {mode.isDesignLibrary ? ( + route && ( + import('.sitecore/import-map.server')} + /> + ) + ) : ( + <> +
+ +
+
+
+ {route && ( + + )} +
+
+
+ +
+ + )} +
+ + ); +}; + +export default Layout; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Providers.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Providers.tsx new file mode 100644 index 0000000000..2bc2eed055 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Providers.tsx @@ -0,0 +1,18 @@ +'use client'; +import React from 'react'; +import { Page, SitecoreProvider } from '@sitecore-content-sdk/nextjs'; +import scConfig from 'sitecore.config'; +import components from '.sitecore/component-map.client'; + +export default function Providers({ children, page }: { children: React.ReactNode; page: Page }) { + return ( + import('.sitecore/import-map.client')} + > + {children} + + ); +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Scripts.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Scripts.tsx new file mode 100644 index 0000000000..e47c9518a9 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/Scripts.tsx @@ -0,0 +1,17 @@ +'use client'; +import { JSX } from 'react'; +import { EditingScripts } from '@sitecore-content-sdk/nextjs'; +import CdpPageView from 'components/content-sdk/CdpPageView'; +import BYOCInit from './byoc'; + +const Scripts = (): JSX.Element => { + return ( + <> + + + + + ); +}; + +export default Scripts; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/layout.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/layout.tsx new file mode 100644 index 0000000000..277fcea3d1 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/layout.tsx @@ -0,0 +1,19 @@ +import { setCachedPageParams } from '@sitecore-content-sdk/nextjs'; + +export default async function SiteLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ site: string; locale: string }>; +}) { + const { site, locale } = await params; + + // Update the cached page info with the current site and locale values. + // This ensures the notFound page can access the correct site and locale information when rendered + // without opting out of SSG by using functions like `headers()`. + setCachedPageParams({ locale, site }); + + return <>{children}; +} + diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/not-found.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/not-found.tsx new file mode 100644 index 0000000000..216000290e --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/not-found.tsx @@ -0,0 +1,47 @@ +import { Suspense } from 'react'; +import Link from 'next/link'; +import { ErrorPage, getCachedPageParams } from '@sitecore-content-sdk/nextjs'; +import client from 'lib/sitecore-client'; +import scConfig from 'sitecore.config'; +import Layout from 'src/Layout'; +import Providers from 'src/Providers'; +import { NextIntlClientProvider } from 'next-intl'; + +export default function NotFound() { + return ( + }> + + + ); +} + +function StaticNotFound() { + return ( +
+

Page not found

+

This page does not exist.

+ Go to the Home page +
+ ); +} + +async function SitecoreLocaleNotFound() { + const { site, locale } = getCachedPageParams(); + + const page = await client.getErrorPage(ErrorPage.NotFound, { + site: site || scConfig.defaultSite, + locale: locale || scConfig.defaultLanguage, + }); + + if (page) { + return ( + + + + + + ); + } + + return ; +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/page.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/page.tsx new file mode 100644 index 0000000000..a9aa868b92 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/[locale]/[[...path]]/page.tsx @@ -0,0 +1,87 @@ +import { isDesignLibraryPreviewData } from '@sitecore-content-sdk/nextjs/editing'; +import { notFound } from 'next/navigation'; +import { draftMode } from 'next/headers'; +<% if (prerender === 'SSG') { -%> +import { SiteInfo } from '@sitecore-content-sdk/nextjs'; +import sites from '.sitecore/sites.json'; +import { routing } from 'src/i18n/routing'; +import scConfig from 'sitecore.config'; +<% } -%> +import client from 'src/lib/sitecore-client'; +import { getSitecorePage } from 'src/lib/cache/get-sitecore-page'; +import Layout, { RouteFields } from 'src/Layout'; +import Providers from 'src/Providers'; +import { NextIntlClientProvider } from 'next-intl'; +import { setRequestLocale } from 'next-intl/server'; + +type PageProps = { + params: Promise<{ site: string; locale: string; path?: string[]; [key: string]: string | string[] | undefined }>; + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; +}; + +export default async function Page({ params, searchParams }: PageProps) { + const { site, locale, path } = await params; + + // Set site and locale to be available in src/i18n/request.ts for fetching the dictionary + setRequestLocale(`${site}_${locale}`); + + const draft = await draftMode(); + + // Fetch the page data from Sitecore + let page; + if (draft.isEnabled) { + const editingParams = await searchParams; + if (isDesignLibraryPreviewData(editingParams)) { + page = await client.getDesignLibraryData(editingParams); + } else { + page = await client.getPreview(editingParams); + } + } else { + page = await getSitecorePage({ site, locale, path: path ?? [] }); + } + + // If the page is not found, return a 404 + if (!page) { + notFound(); + } + + return ( + + + + + + ); +} + +<% if (prerender === 'SSG') { -%> +// This function gets called at build and export time to determine +// pages for SSG ("paths", as tokenized array). +export const generateStaticParams = async () => { + if (process.env.NODE_ENV !== 'development' && scConfig.generateStaticPaths) { + return await client.getAppRouterStaticParams( + sites.map((site: SiteInfo) => site.name), + routing.locales.slice() + ); + } + // Next.js 16 requires at least one result + // Return a default param for the root page + return [ + { + site: sites[0]?.name || 'default', + locale: routing.defaultLocale || scConfig.defaultLanguage, + path: [], + }, + ]; +}; +<% } -%> +// Metadata fields for the page. +export const generateMetadata = async ({ params }: PageProps) => { + const { path, site, locale } = await params; + + // The same call as for rendering the page. Should be cached by default react behavior + const page = await getSitecorePage({ site, locale, path: path ?? [] }); + return { + title: (page?.layout.sitecore.route?.fields as RouteFields)?.Title?.value?.toString() || 'Page', + }; +}; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/layout.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/layout.tsx new file mode 100644 index 0000000000..535650f337 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/[site]/layout.tsx @@ -0,0 +1,20 @@ +import { draftMode } from 'next/headers'; +import Bootstrap from 'src/Bootstrap'; + +export default async function SiteLayout({ + children, + params, +}: { + children: React.ReactNode; + params: Promise<{ site: string }>; +}) { + const { site } = await params; + const { isEnabled } = await draftMode(); + + return ( + <> + + {children} + + ); +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/config/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/config/route.ts new file mode 100644 index 0000000000..c808ea390b --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/config/route.ts @@ -0,0 +1,15 @@ +import { createEditingConfigRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler'; +import components from '.sitecore/component-map'; +import clientComponents from '.sitecore/component-map.client'; +import metadata from '.sitecore/metadata.json'; + +/** + * This API route is used by Sitecore Editor in XM Cloud + * to determine feature compatibility and configuration. + */ + +export const { GET, OPTIONS } = createEditingConfigRouteHandler({ + components, + clientComponents, + metadata, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/render/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/render/route.ts new file mode 100644 index 0000000000..0a0d0a5978 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/editing/render/route.ts @@ -0,0 +1,15 @@ +import { createEditingRenderRouteHandlers } from '@sitecore-content-sdk/nextjs/route-handler'; + +/** + * API route to handler Sitecore Editor rendeing. + * When using custom server URL, it should match the rendering host from your Sitecore configuration, + * (see the settings item under /sitecore/content//Settings/Site Grouping). + * + * The route handler will: + * 1. Extract data about the route we need to render from the Sitecore Editor GET request + * 2. Enable Next.js Draft Mode + * 3. Pass preview data as query string parameters, alongside required headers and cookies to an internal editing request + * 4. Return the rendered HTML for editing mode + */ + +export const { GET, POST, OPTIONS } = createEditingRenderRouteHandlers({}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/route.ts new file mode 100644 index 0000000000..1616f3d7a4 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/route.ts @@ -0,0 +1,3 @@ +import { createRevalidateRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler'; + +export const { POST } = createRevalidateRouteHandler(); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/webhook/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/webhook/route.ts new file mode 100644 index 0000000000..f81e5369d0 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/revalidate/webhook/route.ts @@ -0,0 +1,31 @@ +import { createEdgeWebhookRevalidateRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler'; +import { buildSitecoreDictionaryCacheTag, type SiteInfo } from '@sitecore-content-sdk/nextjs'; +import scConfig from 'sitecore.config'; +import sites from '.sitecore/sites.json'; + +const dictionaryTags = Array.from( + new Set( + (sites as SiteInfo[]) + .map((site) => + buildSitecoreDictionaryCacheTag({ + site: site.name, + locale: site.language || scConfig.defaultLanguage, + }) + ) + .concat( + scConfig.defaultSite + ? [ + buildSitecoreDictionaryCacheTag({ + site: scConfig.defaultSite, + locale: scConfig.defaultLanguage, + }), + ] + : [] + ) + ) +); + +export const { POST } = createEdgeWebhookRevalidateRouteHandler({ + defaultLocale: scConfig.defaultLanguage, + additionalTags: dictionaryTags, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/robots/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/robots/route.ts new file mode 100644 index 0000000000..30725afca6 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/robots/route.ts @@ -0,0 +1,16 @@ +import { createRobotsRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler'; +import sites from '.sitecore/sites.json'; +import client from 'lib/sitecore-client'; + +/** + * API route for serving robots.txt + * + * This Next.js API route handler generates and returns the robots.txt content dynamically + * based on the resolved site name. It is commonly + * used by search engine crawlers to determine crawl and indexing rules. + */ + +export const { GET } = createRobotsRouteHandler({ + client, + sites, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/sitemap/route.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/sitemap/route.ts new file mode 100644 index 0000000000..181eafc426 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/api/sitemap/route.ts @@ -0,0 +1,15 @@ +import { createSitemapRouteHandler } from '@sitecore-content-sdk/nextjs/route-handler'; +import sites from '.sitecore/sites.json'; +import client from 'lib/sitecore-client'; + +/** + * API route for generating sitemap.xml + * + * This Next.js API route handler dynamically generates and serves the sitemap XML for your site. + * The sitemap configuration can be managed within XM Cloud. + */ + +export const { GET } = createSitemapRouteHandler({ + client, + sites, +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/favicon.ico b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..065a432e4186a62ed48411fa6ad2c72fafd73f58 GIT binary patch literal 15086 zcmchd36K@V8GvV55DXG1QyLXj5shGWovc_0}-XynTC^SM||7ef9cg!ZY@Z;1 zB5YI2me$lEs}mdoFG6eANZP(~pI=3ndy3uM_8KxIIFzHuIPw%sfQ#W8 za4*gR%Vj3q375j@a2nhQZ$nFKausbxLAP?pK-=GgF|m}7zM|fEuYYfW1yIg-Z8H~) zy~n~8unbD+vd^EPQ%hr@%|KWRrR2R&{naoT{+npqNZxU4&mwpe^s({Ly0lE|E{3Dv zA#e?*+l1%k)E!#Ne(E=d*(GEsYd%~E`d)p`uXd_`8cczk;0|yfST+ePn*r}Za?L11 zokPHV(g4BcbowlH)mdYpyaGl*Qg*t(G4wC+I%xOPFctm@&wy)aVG-x>Gx!?xy?c8O zI1b0<946(z!}IUJ`6xApzf=FMI|pSu!rPFPnau0sGeFyIHyPZ6x57y9KGPo#gR|g# zaGpkiV{~l#+VL2pyaPFY%YvQ{$^Q|?fMX8j$<(#EXL1gB?gZ|mWV>Yk6Y8`{|LN!2 z;o;cS=y?}B>ohkGHpmh{Hc;OK^lNf_A#dN& za5?ze(XJmd)a9ON0N2q~umoJ|?xQE+Q*bP0!wq+ETY`~^z(KZ zp6HCMZg2s(kKNOk!AGFo<3PW=$D9l2XcQ#v4*BJj?*k{os+=~rQ9cO1pD>}_S>&w; zQ$Sm0g1**XZE`HZ#>M211NU?&3#oH<4qTT}XB+Ds8*io^|3mH;;M&mcOF*9o+d`@h zr$Hg3&NkEG@tivS(C#%~YUIe*KkhMYH`cnQg6$!_mNL(Z@SIHNQeKx+_ZsCJm(t(f zI_1NQ;QBos=0nmx^N+yUkQIkK%d*y%{pLW2uKTRRRBYR(XCr7vTdNv)E z)5i67ChU|`Z-38|&Dv{cUVmf{fyI#JTg&v{8IW~$y-)dG&>ilBEE$e*XrBJ|mLt0Y zwg=a^F*9j{?z(|E?X|U3f8=)qW8^cSuca1#4S8#O zSddAZzVIf5u_aT-)!a7p`7ToQYzh~{bl44i&rjMC^6r`Kf(_)INAIwqKAn0#?i<>F zmnnKW!w=v}NZS#f-4|G{I z17$o6n`iayh%#hr`>~LG2TR)SzVQ7yGp19%0Xz!HKI!}{>O1^gMy7lCB*?Q}JLbTq zrF=!bF|`iTb|;_bQGX-R(mH8p3^)@?`6I~o9J~<*LvP3(3w6f19^jd; ze9xA=_#WhYZ$1}V{N9%whwb4k(9c0usZ>3$!EJCp{00t(L&3G?J!~922FsvSoCtdK z-+55>|GlKf(1pIvuQ4mgEH~9Yee1iSciB?wBTJ_9dNQ{zXHIX+K?Y;^2 zhUV}3ZMCzde&}#t>$KcbZ=|`2ZHCi?-7f@ z@feSMe-QVuWuA)SZS4YtZ(}pOjGOdfEuBL@3sx+xw99DY1cTeXIu?9rj1|BSqt4@Pw@Pj4a?yx2>aG^;6s=Lqrm;z z?r(g^@T@%mZh>V`PF%5#apQJyE#u|Z|LC_lOaS+D7-wr!?+BBDzoaYwNBbHuhLqB0 zTyhSir5N=RZT2t60@~@b#gL_YCFR~_9s%z&)8J;f5%jn7pLb7xo4UhF*+9MPW+9|? z8>c*b?*(;)Smr%Qn~Y%#z&JM+w9WOHma~Mifmt?C?mM=!(|x?J+zrl$^Qqi7;RSda zwCgSS0_>|z`p3ANHHVgMp0t5y&(BLCty`bzWA(ibvq0H1;Xg1JoR62G0G8=XnY|5Bk$H@mJ6v2Er*Y2ByNJpg#Tk5NNNm{C?`aNNb1m6JHiRz9u9Tike>htLPc_& zl*-e#8@T4)1^3A^cmSLa*T*IB4g@{=_;i?_D6?)U42D+!-6?&#!wukGydR96pMrAr zi~BR^H~km9k}7k(w)y|(=-UtU`yG&+SMNC2z-kCKSf7qp6H58HoH}H>m))D%5OjS; z-v2KMal&%TlFycnDo01DHptiy9IyL)6)4wK{mJn*@SN6vVJX?=)FHDPTmu(@>)>&i z0p~zkzxy@#$aH);I!d)cMjvo)=YZ>5Cc&wY9IyU&ZzT154{nY%rS#<0(SI*+?I-0L zAFl%UcPM{@yfH33n>ue>=E-iUoW9+_u_k4jHxAsHD!-fOp%B)H?X+)bOM3F^>DvQb z|DQmx;Y;!(!8r+K#>G=$YtRp&+_V7e=jmvvoW5P)Ab1Si@Atvqius^tG0)pW1&oDI zZrWs9OL|(WN6rRtBK#H>LQ==IJo84-bO!E5K7BqJ$IH62m9hgF8-n9c>h^5kJM<&p z2|VwTZOnU~^$+b@o6=X^-Vc*8SGi-LY3xBJ&zperp0+{19SvpgN!mK6JA>z6TDNzE z&4S)!O8M@PUW?je%$N!_koz~=)OCh!;9OV&>9H)LtfW7wKL9+x(tUk4rpy5M(hwK` z{b3Lo+l(dZO^?~W>OMF*-n=~R_5fpNmYiT$BlRobb4bRIP@hbnp-owLsbCZB`+(~t zd0r%CmXkLIjfd(Ww>2sKj3fKN6mUO&11;&b%~H4toS)q9*5%rQjCH{Ed^S7`+OiUy z1N9kS)%hs=6?TQPzB|Qre1NE}X!=aUHSW;m6?kz6Q_obHMerEgTNs^`3{mBL{nOAsE2Z8s%>erwH^P|h@7M=t z^$TUx_l5gG8B@XaVZSAtCt?{qv8yM+Ea1U6% z0`7uez%H;842DVYG1$g&Y?2%)&l|&4pl!BsPPNfKVgKkq*KO})-;k%=JL*qB{qKV@ z%>DZT+zp4pT4QUd;@c^sD6sNs@!S}wDy*TVj-XPcigd~<>OYO5C!Sc-$G-mqH(reZ literal 0 HcmV?d00001 diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/global-error.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/global-error.tsx new file mode 100644 index 0000000000..61050f3293 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/global-error.tsx @@ -0,0 +1,51 @@ +'use client'; +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { ErrorPage, Page } from '@sitecore-content-sdk/nextjs'; +import client from 'lib/sitecore-client'; +import scConfig from 'sitecore.config'; +import Providers from 'src/Providers'; +import Layout from 'src/Layout'; + +export default function GlobalError() { + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function loadErrorPage() { + try { + const page = await client.getErrorPage(ErrorPage.InternalServerError, { + site: scConfig.defaultSite, + locale: scConfig.defaultLanguage, + }); + setPage(page); + } catch { + setPage(null); + } + + setLoading(false); + } + + loadErrorPage(); + }, []); + + if (loading) { + return
Loading...
; + } + + if (page) { + return ( + + + + ); + } + + return ( +
+

500 Internal Server Error

+

There is a problem with the resource you are looking for, and it cannot be displayed.

+ Go to the Home page +
+ ); +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/globals.css b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/globals.css new file mode 100644 index 0000000000..cdc8713e73 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/globals.css @@ -0,0 +1 @@ +@import '../assets/main.css'; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/layout.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/layout.tsx new file mode 100644 index 0000000000..c95127b9e6 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/layout.tsx @@ -0,0 +1,9 @@ +import './globals.css'; + +export default function RootLayout({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/not-found.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/not-found.tsx new file mode 100644 index 0000000000..2749f9f115 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/app/not-found.tsx @@ -0,0 +1,46 @@ +import { Suspense } from 'react'; +import Link from 'next/link'; +import client from 'lib/sitecore-client'; +import scConfig from 'sitecore.config'; +import { ErrorPage } from '@sitecore-content-sdk/nextjs'; +import Layout from 'src/Layout'; +import Providers from 'src/Providers'; + +export default function NotFound() { + return ( + }> + + + ); +} + +function StaticNotFound() { + return ( +
+

Page not found

+

This page does not exist.

+ Go to the Home page +
+ ); +} + +async function SitecoreRootNotFound() { + if (!scConfig.defaultSite) { + return ; + } + + const page = await client.getErrorPage(ErrorPage.NotFound, { + site: scConfig.defaultSite, + locale: scConfig.defaultLanguage, + }); + + if (page) { + return ( + + + + ); + } + + return ; +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/assets/main.css b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/assets/main.css new file mode 100644 index 0000000000..d4b5078586 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/assets/main.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.client.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.client.tsx new file mode 100644 index 0000000000..8f1a8d60d1 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.client.tsx @@ -0,0 +1,20 @@ +'use client'; +import * as FEAAS from '@sitecore-feaas/clientside/react'; +/** + * You can import your own client components here + * @example + * import './MyClientComponent'; + * @example + * import 'src/otherFolder/MyOtherComponent'; + */ + +// An important boilerplate component that prevents BYOC components from being optimized away and allows then. Should be kept in this file. +const ClientsideComponent = (props: FEAAS.ExternalComponentProps) => FEAAS.ExternalComponent(props); +/** + * Clientside BYOC component will be rendered in the browser, so that external components: + * - Can have access to DOM apis, including network requests + * - Use clientside react hooks like useEffect. + * - Be implemented as web components. + */ + +export default ClientsideComponent; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.hybrid.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.hybrid.ts new file mode 100644 index 0000000000..229ce32572 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.hybrid.ts @@ -0,0 +1,10 @@ +/** + * You can import your own hybrid (server render + hydration) components below + * @example + * import './MyHybridComponent'; + * @example + * import 'src/otherFolder/MyOtherComponent'; + */ + +// eslint-disable-next-line import/no-anonymous-default-export +export default {}; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.tsx new file mode 100644 index 0000000000..0970525779 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/byoc/index.tsx @@ -0,0 +1,39 @@ +import React, { JSX } from 'react'; +import * as FEAAS from '@sitecore-feaas/clientside/react'; +import * as Events from '@sitecore-content-sdk/events'; +import { LayoutServicePageState, SitecoreProviderReactContext } from '@sitecore-content-sdk/nextjs'; +import '@sitecore/components/context'; +import dynamic from 'next/dynamic'; +import config from 'sitecore.config'; +/** + * This is an out-of-box bundler for External components (BYOC) (see Sitecore documentation for more details) + * It enables registering components in client-only or SSR/hybrid contexts + * It's recommended to not modify this file - please add BYOC imports in corresponding index.*.ts files instead + */ + +// Import your client-only components via client-bundle. Nextjs's dynamic() call will ensure they are only rendered client-side +const ClientBundle = dynamic(() => import('./index.client')); + +// As long as component bundle is exported and rendered on page (as an empty element), client-only BYOC components are registered and become available +// The rest of components will be regsitered in both server and client-side contexts when this module is imported into Layout +FEAAS.enableNextClientsideComponents(dynamic, ClientBundle); + +// Import your hybrid (server rendering with client hydration) components via index.hybrid.ts +import './index.hybrid'; + +const BYOCInit = (): JSX.Element | null => { + const { page } = React.useContext(SitecoreProviderReactContext); + const { pageState } = page.layout.sitecore.context; + // Set context properties to be available within BYOC components + FEAAS.setContextProperties({ + sitecoreEdgeUrl: config.api.edge?.edgeUrl, + sitecoreEdgeContextId: config.api.edge?.contextId, + pageState: pageState || LayoutServicePageState.Normal, + siteName: page.siteName || config.defaultSite, + eventsSDK: Events, + }); + + return ; +}; + +export default BYOCInit; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/CdpPageView.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/CdpPageView.tsx new file mode 100644 index 0000000000..5c86cd9204 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/CdpPageView.tsx @@ -0,0 +1,59 @@ +'use client'; +import { CdpHelper, useSitecore } from '@sitecore-content-sdk/nextjs'; +import { useEffect, JSX } from 'react'; +import { pageView } from '@sitecore-content-sdk/events'; +import config from 'sitecore.config'; + +/** + * This is the CDP page view component. + * See Sitecore Content SDK documentation for details. + * https://www.npmjs.com/package/@sitecore-content-sdk/events + */ +const CdpPageView = (): JSX.Element => { + const { + page: { layout, siteName, mode }, + } = useSitecore(); + const { route, context } = layout.sitecore; + + /** + * Determines if the page view events should be turned off. + * IMPORTANT: You should implement based on your cookie consent management solution of choice. + * By default it is disabled in development mode + */ + const disabled = () => { + return process.env.NODE_ENV === 'development'; + }; + + useEffect(() => { + // Do not create events in editing or preview mode or if missing route data + if (!mode.isNormal || !route?.itemId) { + return; + } + // Do not create events if disabled (e.g. we don't have consent) + if (disabled()) { + return; + } + + const language = route.itemLanguage || config.defaultLanguage; + const scope = config.personalize?.scope; + + const pageVariantId = CdpHelper.getPageVariantId( + route.itemId, + language, + context.variantId as string, + scope + ); + // there can be cases where Events are not initialized which are expected to reject + pageView({ + channel: 'WEB', + currency: 'USD', + page: route.name, + pageVariantId, + language, + }).catch((e) => console.debug(e)); + }, [mode, route, context.variantId, siteName]); + + return <>; +}; + +export default CdpPageView; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/SitecoreStyles.tsx b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/SitecoreStyles.tsx new file mode 100644 index 0000000000..daa6cb2fff --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/components/content-sdk/SitecoreStyles.tsx @@ -0,0 +1,32 @@ +'use client'; +import { LayoutServiceData, HTMLLink } from '@sitecore-content-sdk/nextjs'; +import client from 'src/lib/sitecore-client'; + +/** + * Component to render `` elements for Sitecore styles + */ +const SitecoreStyles = ({ + layoutData, + enableStyles, + enableThemes, +}: { + layoutData: LayoutServiceData; + enableStyles?: boolean; + enableThemes?: boolean; +}) => { + const headLinks = client.getHeadLinks(layoutData, { enableStyles, enableThemes }); + + if (headLinks.length === 0) { + return null; + } + + return ( + <> + {headLinks.map(({ rel, href }: HTMLLink) => ( + + ))} + + ); +}; + +export default SitecoreStyles; \ No newline at end of file diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/request.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/request.ts new file mode 100644 index 0000000000..57620f110a --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/request.ts @@ -0,0 +1,27 @@ +import { getRequestConfig, GetRequestConfigParams } from 'next-intl/server'; +import { hasLocale } from 'next-intl'; +import { routing } from './routing'; +import { getSitecoreDictionary } from 'src/lib/cache/get-sitecore-dictionary'; + +export default getRequestConfig(async ({ requestLocale }: GetRequestConfigParams) => { + // Provide a static locale, fetch a user setting, + // read from `cookies()`, `headers()`, etc. + // Since this function is executed during the Server Components render pass, you can call functions like cookies() and headers() to return configuration that is request-specific. https://next-intl.dev/docs/usage/configuration + + // set by the catch-all route setRequestLocale + // to support SSG and multisite here we expect both site and locale in the format {site}_{locale} + const requested = await requestLocale; + const [parsedSite, parsedLocale] = requested?.split('_') || []; + const locale = hasLocale(routing.locales, parsedLocale) ? parsedLocale : routing.defaultLocale; + + const messages: Record = {}; + messages[parsedSite] = await getSitecoreDictionary({ + locale, + site: parsedSite, + }); + + return { + locale, + messages, + }; +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/routing.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/routing.ts new file mode 100644 index 0000000000..0423cca380 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/i18n/routing.ts @@ -0,0 +1,15 @@ +import { defineRouting } from 'next-intl/routing'; +import sitecoreConfig from 'sitecore.config'; + +export const routing = defineRouting({ + // A list of all locales that are supported + locales: [sitecoreConfig.defaultLanguage], + + // Used when no locale matches + defaultLocale: sitecoreConfig.defaultLanguage, + + // No prefix is added for the default locale ("as-needed"). + // For other configuration options, refer to the next-intl documentation: + // https://next-intl.dev/docs/routing/configuration + localePrefix: 'as-needed', +}); diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-dictionary.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-dictionary.ts new file mode 100644 index 0000000000..0dc80ef970 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-dictionary.ts @@ -0,0 +1,20 @@ +import { buildSitecoreDictionaryCacheTag, DictionaryPhrases } from '@sitecore-content-sdk/nextjs'; +import { cacheTag } from 'next/cache'; +import client from 'src/lib/sitecore-client'; + +type GetSitecoreDictionaryParams = { + site: string; + locale: string; +}; + +/** + * Fetches dictionary phrases using Next.js Cache Components and a deterministic dictionary tag. + */ +export async function getSitecoreDictionary(params: GetSitecoreDictionaryParams): Promise { + 'use cache'; + + const { site, locale } = params; + cacheTag(buildSitecoreDictionaryCacheTag({ site, locale })); + + return client.getDictionary({ site, locale }); +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-page.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-page.ts new file mode 100644 index 0000000000..1b49c9c462 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/cache/get-sitecore-page.ts @@ -0,0 +1,33 @@ +import { collectSitecorePageCacheTags, Page } from '@sitecore-content-sdk/nextjs'; +import { cacheTag } from 'next/cache'; +import client from 'src/lib/sitecore-client'; + +type GetSitecorePageParams = { + site: string; + locale: string; + path: string[]; +}; + +/** + * Gets page data using Next.js Cache Components and deterministic Sitecore cache tags. + */ +export async function getSitecorePage(params: GetSitecorePageParams): Promise { + 'use cache'; + + const { site, locale, path } = params; + const page = await client.getPage(path, { site, locale }); + + const personalizedPathname = path.length ? `/${path.join('/')}` : '/'; + const tags = collectSitecorePageCacheTags({ + site, + locale, + personalizedPathname, + route: page?.layout?.sitecore?.route ?? {}, + }); + + for (const tag of tags) { + cacheTag(tag); + } + + return page; +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/component-props/index.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/component-props/index.ts new file mode 100644 index 0000000000..f0e3a3e6b4 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/component-props/index.ts @@ -0,0 +1,33 @@ +import { ComponentParams, ComponentRendering, Page } from '@sitecore-content-sdk/nextjs'; + +/** + * Shared component props + */ +export type ComponentProps = { + rendering: ComponentRendering; + params: ComponentParams & { + /** + * The identifier for the rendering + */ + RenderingIdentifier?: string; + /** + * The styles for the rendering + * This value is calculated by the Placeholder component + */ + styles?: string; + /** + * The enabled placeholders for the rendering + */ + EnabledPlaceholders?: string; + }; +}; + +/** + * Component props with context + * You can access `page` by withSitecore/useSitecore + * @example withSitecore()(ContentBlock) + * @example const { page } = useSitecore() + */ +export type ComponentWithContextProps = ComponentProps & { + page: Page; +}; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/image-remote-patterns.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/image-remote-patterns.ts new file mode 100644 index 0000000000..45e0874a0f --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/image-remote-patterns.ts @@ -0,0 +1,17 @@ +/** + * Shared image allowlist for `next.config` and runtime helpers (e.g. FEAAS `next/image` wiring). + * Keep this separate from `next.config.ts` so components never import the Next config file + * (which would pull build-only code such as `next-intl/plugin` into the Sitecore import map). + */ +export const imageRemotePatterns = [ + { + protocol: 'https' as const, + hostname: 'edge*.**', + port: '', + }, + { + protocol: 'https' as const, + hostname: 'xmc-*.**', + port: '', + }, +]; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/sitecore-client.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/sitecore-client.ts new file mode 100644 index 0000000000..9563d5757e --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/lib/sitecore-client.ts @@ -0,0 +1,8 @@ +import { SitecoreClient } from '@sitecore-content-sdk/nextjs/client'; +import scConfig from 'sitecore.config'; + +const client = new SitecoreClient({ + ...scConfig, +}); + +export default client; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/proxy.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/proxy.ts new file mode 100644 index 0000000000..147909aba5 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/src/proxy.ts @@ -0,0 +1,110 @@ +import { NextFetchEvent, type NextRequest } from 'next/server'; +import { + defineProxy, + AppRouterMultisiteProxy, + PersonalizeProxy, + RedirectsProxy, + LocaleProxy, + BotTrackingProxy, +} from '@sitecore-content-sdk/nextjs/proxy'; +import sites from '.sitecore/sites.json'; +import scConfig from 'sitecore.config'; +import { routing } from './i18n/routing'; + +export default function proxy(req: NextRequest, event: NextFetchEvent) { + // BotTrackingProxy will detect and track bots before any other proxies run + const botTracking = new BotTrackingProxy({ + ...scConfig.api.edge, + sites, + fetchEvent: event, + }); + + // LocaleProxy and AppRouterMultisiteProxy must always run for App Router routing + const locale = new LocaleProxy({ + /** + * List of sites for site resolver to work with + */ + sites, + /** + * List of all supported locales configured in routing.ts + */ + locales: routing.locales.slice(), + // This function determines if the proxy should be turned off on per-request basis. + // Certain paths are ignored by default (e.g. files and Next.js API routes), but you may wish to disable more. + // This is an important performance consideration since Next.js Edge proxy runs on every request. + // in multilanguage scenarios, we need locale proxy to always run first to ensure locale is set and used correctly by the rest of the proxies + skip: () => false, + }); + + const multisite = new AppRouterMultisiteProxy({ + /** + * List of sites for site resolver to work with + */ + sites, + ...scConfig.multisite, + // This function determines if the proxy should be turned off on per-request basis. + // Certain paths are ignored by default (e.g. files and Next.js API routes), but you may wish to disable more. + // This is an important performance consideration since Next.js Edge proxy runs on every request. + skip: () => false, + }); + + // Instantiate proxies - they will use Edge config if available, otherwise fall back to local config + // Each proxy will skip processing if required API configuration is not available + const redirects = new RedirectsProxy({ + /** + * List of sites for site resolver to work with + */ + sites, + ...scConfig.api.edge, + ...scConfig.api.local, + ...scConfig.redirects, + // This function determines if the proxy should be turned off on per-request basis. + // Certain paths are ignored by default (e.g. Next.js API routes), but you may wish to disable more. + // By default it is disabled while in development mode. + // This is an important performance consideration since Next.js Edge proxy runs on every request. + skip: () => false, + }); + + const personalize = new PersonalizeProxy({ + /** + * List of sites for site resolver to work with + */ + sites, + ...scConfig.api.edge, + ...scConfig.personalize, + // This function determines if the proxy should be turned off on per-request basis. + // Certain paths are ignored by default (e.g. Next.js API routes), but you may wish to disable more. + // By default it is disabled while in development mode. + // This is an important performance consideration since Next.js Edge proxy runs on every request. + // NOTE: Personalize requires Edge configuration and cannot work with local containers. + // The proxy will disable itself if Edge config is not present. + skip: () => false, + // This is an example of how to provide geo data for personalization. + // The provided callback will be called on each request to extract geo data. + // extractGeoDataCb: () => { + // return { + // city: 'Athens', + // country: 'Greece', + // region: 'Attica', + // }; + // }, + }); + + return defineProxy(botTracking, locale, multisite, redirects, personalize).exec(req); +} + +export const config = { + /* + * Match all paths except for: + * 1. API route handlers + * 2. /_next (Next.js internals) + * 3. /sitecore/api (Sitecore API routes) + * 4. /- (Sitecore media) + * 5. /healthz (Health check) + * 7. all root files inside /public + */ + matcher: [ + '/', + '/((?!api/|sitemap|robots|_next/|healthz|sitecore/api/|-/|favicon.ico|sc_logo.svg).*)', + ], +}; diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/tsconfig.json b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/tsconfig.json new file mode 100644 index 0000000000..d564733310 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/tsconfig.json @@ -0,0 +1,48 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "components/*": [ + "src/components/*" + ], + "lib/*": [ + "src/lib/*" + ], + "temp/*": [ + "src/temp/*" + ], + "assets/*": [ + "src/assets/*" + ], + ".sitecore/*": [ + ".sitecore/*" + ], + <%_ if (helper.isDev) { _%> + "next/*": ["node_modules/next/*"], + <%_ } _%> + }, + "target": "ES2017", + "types": ["node"], + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "strictFunctionTypes": false, + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router/src/assets/main.css b/packages/create-content-sdk-app/src/templates/nextjs-app-router/src/assets/main.css new file mode 100644 index 0000000000..d4b5078586 --- /dev/null +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router/src/assets/main.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts new file mode 100644 index 0000000000..d3a63a0b32 --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-cache-tags.test.ts @@ -0,0 +1,123 @@ +import { expect } from 'chai'; +import { + buildSitecoreDictionaryCacheTag, + buildSitecoreItemCacheTag, + buildSitecoreItemCacheTagFromRouteData, + buildSitecorePersonalizedPageVariantCacheTag, + buildSitecoreRouteCacheTag, + dedupeSitecoreCacheTags, + normalizeSitecoreItemIdForCacheTag, + sanitizeSitecoreCacheTagSegment, + SITECORE_CONTENT_CACHE_TAG_PREFIX, +} from './sitecore-cache-tags'; + +describe('sitecore-cache-tags', () => { + describe('sanitizeSitecoreCacheTagSegment', () => { + it('lowercases and replaces reserved characters', () => { + expect(sanitizeSitecoreCacheTagSegment(' MySite ')).to.equal('mysite'); + expect(sanitizeSitecoreCacheTagSegment('a/b:c')).to.equal('a_b_c'); + expect(sanitizeSitecoreCacheTagSegment('x y\tz')).to.equal('x_y_z'); + }); + }); + + describe('normalizeSitecoreItemIdForCacheTag', () => { + it('strips braces and lowercases', () => { + expect(normalizeSitecoreItemIdForCacheTag('{52961EEA-BAFD-5287-A532-A72E36BD8A36}')).to.equal( + '52961eea-bafd-5287-a532-a72e36bd8a36' + ); + }); + }); + + describe('buildSitecoreRouteCacheTag', () => { + it('builds home path key when segments omitted', () => { + expect(buildSitecoreRouteCacheTag({ site: 'Website', locale: 'en-US' })).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:website:en-us:_` + ); + }); + + it('joins path segments', () => { + expect( + buildSitecoreRouteCacheTag({ site: 'Website', locale: 'en-US', pathSegments: ['About', 'Team'] }) + ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:website:en-us:about/team`); + }); + }); + + describe('buildSitecoreItemCacheTag', () => { + it('uses latest when version omitted', () => { + expect( + buildSitecoreItemCacheTag({ + itemId: '{52961EEA-BAFD-5287-A532-A72E36BD8A36}', + locale: 'en-US', + }) + ).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:52961eea-bafd-5287-a532-a72e36bd8a36:en-us:latest` + ); + }); + + it('includes integer version', () => { + expect( + buildSitecoreItemCacheTag({ + itemId: '52961eea-bafd-5287-a532-a72e36bd8a36', + locale: 'en-US', + version: 4, + }) + ).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:52961eea-bafd-5287-a532-a72e36bd8a36:en-us:v4` + ); + }); + }); + + describe('buildSitecoreDictionaryCacheTag', () => { + it('scopes by site and locale', () => { + expect(buildSitecoreDictionaryCacheTag({ site: 'Website', locale: 'da-DK' })).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:website:da-dk` + ); + }); + }); + + describe('buildSitecorePersonalizedPageVariantCacheTag', () => { + it('uses variant id only when no component ids', () => { + expect(buildSitecorePersonalizedPageVariantCacheTag({ variantId: 'Variant-A' })).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:variant-a` + ); + }); + + it('sorts component variant ids for stability', () => { + expect( + buildSitecorePersonalizedPageVariantCacheTag({ + variantId: 'v1', + componentVariantIds: ['z', 'a'], + }) + ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:v1:a+z`); + }); + }); + + describe('buildSitecoreItemCacheTagFromRouteData', () => { + it('returns null without itemId', () => { + expect(buildSitecoreItemCacheTagFromRouteData({}, 'en-US')).to.equal(null); + }); + + it('uses itemLanguage from route when set', () => { + expect( + buildSitecoreItemCacheTagFromRouteData( + { itemId: '{A1111111-1111-1111-1111-111111111111}', itemLanguage: 'fr-FR', itemVersion: 2 }, + 'en-US' + ) + ).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111-1111-1111-1111-111111111111:fr-fr:v2`); + }); + + it('falls back to fallbackLocale', () => { + expect( + buildSitecoreItemCacheTagFromRouteData({ itemId: 'a1111111111111111111111111111111' }, 'en-US') + ).to.equal( + `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:a1111111111111111111111111111111:en-us:latest` + ); + }); + }); + + describe('dedupeSitecoreCacheTags', () => { + it('preserves order and removes duplicates', () => { + expect(dedupeSitecoreCacheTags(['a', 'b', 'a', 'c', 'b'])).to.deep.equal(['a', 'b', 'c']); + }); + }); +}); diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.ts b/packages/nextjs/src/cache/sitecore-cache-tags.ts new file mode 100644 index 0000000000..3e05a7405d --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-cache-tags.ts @@ -0,0 +1,165 @@ +/** + * Stable cache tag strings for Sitecore content (Next.js `cacheTag`, `unstable_cache` tags, `revalidateTag`). + * Tags are deterministic for the same logical inputs so app code and invalidation webhooks stay aligned. + * @public + */ +export const SITECORE_CONTENT_CACHE_TAG_PREFIX = 'sc'; + +/** + * Sanitizes a single segment for use inside Sitecore cache tags. + * Colons are reserved as delimiters; slashes and whitespace are normalized for stable keys. + * @param {string} value - Raw segment (site name, locale, path segment, etc.). + * @public + */ +export function sanitizeSitecoreCacheTagSegment(value: string): string { + return value.trim().toLowerCase().replace(/[/:\s]+/g, '_'); +} + +/** + * Normalizes a Sitecore item GUID for use in cache tags (lowercase, no braces). + * @param {string} itemId - Sitecore item id or GUID string. + * @public + */ +export function normalizeSitecoreItemIdForCacheTag(itemId: string): string { + return itemId.trim().toLowerCase().replace(/[{}]/g, ''); +} + +export type BuildSitecoreRouteCacheTagParams = { + site: string; + locale: string; + /** + * Path segments after site/locale (e.g. `['about']` or `['products', 'sku-1']`). + * Empty or omitted means the site home route for that locale. + */ + pathSegments?: string[]; +}; + +/** + * Tag for a resolved route (site + language + logical path). Use for URL-level invalidation. + * @param {BuildSitecoreRouteCacheTagParams} params - Site, locale, and optional path segments. + * @public + */ +export function buildSitecoreRouteCacheTag(params: BuildSitecoreRouteCacheTagParams): string { + const site = sanitizeSitecoreCacheTagSegment(params.site); + const locale = sanitizeSitecoreCacheTagSegment(params.locale); + const segments = (params.pathSegments ?? []).map((s) => sanitizeSitecoreCacheTagSegment(s)); + const pathKey = segments.length > 0 ? segments.join('/') : '_'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:${site}:${locale}:${pathKey}`; +} + +export type BuildSitecoreItemCacheTagParams = { + itemId: string; + locale: string; + /** + * Published version number, or omit / `undefined` for "latest" (no version in the key). + */ + version?: number; +}; + +/** + * Tag for a layout/route item (and anything else keyed the same way). Use for item-level invalidation. + * @param {BuildSitecoreItemCacheTagParams} params - Item id, locale, and optional published version. + * @public + */ +export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParams): string { + const id = normalizeSitecoreItemIdForCacheTag(params.itemId); + const locale = sanitizeSitecoreCacheTagSegment(params.locale); + const ver = + params.version !== undefined && Number.isFinite(params.version) + ? `v${Math.trunc(params.version)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +export type BuildSitecoreDictionaryCacheTagParams = { + site: string; + locale: string; +}; + +/** + * Tag for dictionary data scoped to site + locale. + * @param {BuildSitecoreDictionaryCacheTagParams} params - Site and locale for the dictionary fetch. + * @public + */ +export function buildSitecoreDictionaryCacheTag(params: BuildSitecoreDictionaryCacheTagParams): string { + const site = sanitizeSitecoreCacheTagSegment(params.site); + const locale = sanitizeSitecoreCacheTagSegment(params.locale); + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; +} + +export type BuildSitecorePersonalizedPageVariantCacheTagParams = { + /** + * Primary personalization variant id from routing / `PageOptions.personalize`. + */ + variantId: string; + /** + * Optional component-level variant ids (order is normalized for stability). + */ + componentVariantIds?: string[]; +}; + +/** + * Tag for a personalized page variant so caches do not bleed across variants. + * @param {BuildSitecorePersonalizedPageVariantCacheTagParams} params - Variant id and optional component variant ids. + * @public + */ +export function buildSitecorePersonalizedPageVariantCacheTag( + params: BuildSitecorePersonalizedPageVariantCacheTagParams +): string { + const variant = sanitizeSitecoreCacheTagSegment(params.variantId); + const extras = (params.componentVariantIds ?? []) + .map((s) => sanitizeSitecoreCacheTagSegment(s)) + .filter(Boolean) + .sort(); + const suffix = extras.length > 0 ? `:${extras.join('+')}` : ''; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:${variant}${suffix}`; +} + +export type SitecoreRouteDataLike = { + itemId?: string; + itemLanguage?: string; + itemVersion?: number; +}; + +/** + * Builds an item cache tag from layout route data when `itemId` is present. + * Prefers `itemLanguage` from Sitecore when set; otherwise uses `fallbackLocale`. + * @param {SitecoreRouteDataLike} route - Route data from layout (item id, language, version). + * @param {string} fallbackLocale - Locale used when `route.itemLanguage` is not set. + * @returns `null` when `route.itemId` is missing. + * @public + */ +export function buildSitecoreItemCacheTagFromRouteData( + route: SitecoreRouteDataLike, + fallbackLocale: string +): string | null { + if (!route.itemId) { + return null; + } + const locale = route.itemLanguage + ? sanitizeSitecoreCacheTagSegment(route.itemLanguage) + : sanitizeSitecoreCacheTagSegment(fallbackLocale); + const id = normalizeSitecoreItemIdForCacheTag(route.itemId); + const ver = + route.itemVersion !== undefined && Number.isFinite(route.itemVersion) + ? `v${Math.trunc(route.itemVersion)}` + : 'latest'; + return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; +} + +/** + * Deduplicates tag strings while preserving first-seen order. + * @param {string[]} tags - Tag strings possibly containing duplicates. + * @public + */ +export function dedupeSitecoreCacheTags(tags: string[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const t of tags) { + if (!seen.has(t)) { + seen.add(t); + out.push(t); + } + } + return out; +} diff --git a/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.test.ts b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.test.ts new file mode 100644 index 0000000000..606f1f3405 --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.test.ts @@ -0,0 +1,90 @@ +import { expect } from 'chai'; +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + extractSitecoreEdgeContentId, +} from './sitecore-edge-webhook-revalidation'; + +describe('sitecore-edge-webhook-revalidation', () => { + describe('extractSitecoreEdgeContentId', () => { + it('should strip -media suffix', () => { + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-media')).to.equal( + '71B0BA0716214254AEE4429B1A970C8B' + ); + }); + + it('should strip -layout suffix case-insensitively', () => { + expect(extractSitecoreEdgeContentId('71B0BA0716214254AEE4429B1A970C8B-LAYOUT')).to.equal( + '71B0BA0716214254AEE4429B1A970C8B' + ); + }); + + it('should return trimmed base id', () => { + expect(extractSitecoreEdgeContentId(' {abc} ')).to.equal('{abc}'); + }); + + it('should return empty for non-string', () => { + expect(extractSitecoreEdgeContentId(null as unknown as string)).to.equal(''); + }); + }); + + describe('collectSitecoreTagsFromEdgeRevalidateRequestBody', () => { + it('should map updates to sc:item tags using entity_culture', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + updates: [ + { + identifier: '71B0BA0716214254AEE4429B1A970C8B-media', + entity_culture: 'en', + }, + ], + }, + { defaultLocale: 'en' } + ); + expect(tags).to.deep.equal(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + + it('should use defaultLocale when entity_culture is missing', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + updates: [{ identifier: '71B0BA0716214254AEE4429B1A970C8B' }], + }, + { defaultLocale: 'da' } + ); + expect(tags).to.deep.equal(['sc:item:71b0ba0716214254aee4429b1a970c8b:da:latest']); + }); + + it('should pass through full sc: tags in tags array', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + tags: ['sc:dict:default:en'], + }, + { defaultLocale: 'en' } + ); + expect(tags).to.deep.equal(['sc:dict:default:en']); + }); + + it('should map bare ids in tags array to item tags with defaultLocale', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + tags: ['71B0BA0716214254AEE4429B1A970C8B'], + }, + { defaultLocale: 'en' } + ); + expect(tags).to.deep.equal(['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest']); + }); + + it('should dedupe across updates and tags', () => { + const tags = collectSitecoreTagsFromEdgeRevalidateRequestBody( + { + updates: [ + { identifier: '71B0BA0716214254AEE4429B1A970C8B', entity_culture: 'en' }, + { identifier: '71B0BA0716214254AEE4429B1A970C8B-media', entity_culture: 'en' }, + ], + tags: ['sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest'], + }, + { defaultLocale: 'en' } + ); + expect(tags).to.have.length(1); + }); + }); +}); diff --git a/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts new file mode 100644 index 0000000000..06e6734dd4 --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts @@ -0,0 +1,103 @@ +import { + SITECORE_CONTENT_CACHE_TAG_PREFIX, + buildSitecoreItemCacheTag, + dedupeSitecoreCacheTags, +} from './sitecore-cache-tags'; + +/** + * One content change entry as commonly seen in Experience Edge / Content Operations style payloads (POC shape). + * Field names follow community OSR examples; production payloads may include additional fields. + * @public + */ +export type SitecoreEdgeRevalidateUpdate = { + identifier?: string; + entity_definition?: string; + operation?: string; + entity_culture?: string; +}; + +/** + * Request body shape for webhook-driven revalidation (POC-aligned). + * @public + */ +export type SitecoreEdgeRevalidateRequestBody = { + invocation_id?: string; + updates?: SitecoreEdgeRevalidateUpdate[]; + continues?: boolean; + /** + * Extra tag strings. Values starting with the Sitecore cache prefix (`sc:`) are used as-is. + * Bare values are treated as Sitecore item ids (with optional `-media` / `-layout` suffix stripped) + * and mapped to {@link buildSitecoreItemCacheTag} using `defaultLocale`. + */ + tags?: string[]; +}; + +/** + * Strips Experience Edge style suffixes from an `identifier` so the value can be used as an item id in cache tags. + * Handles `{GUID}`, `{GUID}-media`, `{GUID}-layout` style strings. + * @param identifier - Raw identifier from a webhook update row. + * @public + */ +export function extractSitecoreEdgeContentId(identifier: string): string { + if (!identifier || typeof identifier !== 'string') { + return ''; + } + const trimmed = identifier.trim(); + return trimmed.replace(/-(?:media|layout)$/i, ''); +} + +const FULL_TAG_PREFIX = `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:`; + +function isFullSitecoreContentCacheTag(value: string): boolean { + return value.startsWith(FULL_TAG_PREFIX); +} + +export type CollectSitecoreTagsFromEdgeBodyOptions = { + /** + * Used when an update omits `entity_culture`, and when mapping bare item ids in `tags`. + */ + defaultLocale: string; +}; + +/** + * Maps a POC-style Edge webhook JSON body to Content SDK cache tag strings used by + * {@link collectSitecorePageCacheTags} / {@link buildSitecoreItemCacheTag} (`sc:item:...`), so + * `revalidateTag` matches tags registered during cached reads. + * @public + */ +export function collectSitecoreTagsFromEdgeRevalidateRequestBody( + body: SitecoreEdgeRevalidateRequestBody | null | undefined, + options: CollectSitecoreTagsFromEdgeBodyOptions +): string[] { + const { defaultLocale } = options; + const out: string[] = []; + + for (const raw of body?.tags ?? []) { + if (typeof raw !== 'string') { + continue; + } + const s = raw.trim(); + if (!s) { + continue; + } + if (isFullSitecoreContentCacheTag(s)) { + out.push(s); + } else { + const id = extractSitecoreEdgeContentId(s); + if (id) { + out.push(buildSitecoreItemCacheTag({ itemId: id, locale: defaultLocale })); + } + } + } + + for (const u of body?.updates ?? []) { + const id = extractSitecoreEdgeContentId(u?.identifier ?? ''); + if (!id) { + continue; + } + const locale = u?.entity_culture?.trim() || defaultLocale; + out.push(buildSitecoreItemCacheTag({ itemId: id, locale })); + } + + return dedupeSitecoreCacheTags(out).filter(Boolean); +} diff --git a/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts new file mode 100644 index 0000000000..be11ac52b9 --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-page-cache-tags.test.ts @@ -0,0 +1,51 @@ +import { expect } from 'chai'; +import { collectSitecorePageCacheTags } from './sitecore-page-cache-tags'; +import { SITECORE_CONTENT_CACHE_TAG_PREFIX } from './sitecore-cache-tags'; + +describe('collectSitecorePageCacheTags', () => { + const base = { + site: 'Website', + locale: 'en-US', + route: { + itemId: '{11111111-1111-1111-1111-111111111111}', + itemLanguage: 'en-US', + itemVersion: 1, + }, + }; + + it('includes personalization variant tag from pathname', () => { + const tags = collectSitecorePageCacheTags({ + ...base, + personalizedPathname: '/about/_variantId_hero-a', + }); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:`))).to.equal(true); + expect(tags.some((t) => t.includes('hero-a'))).to.equal(true); + }); + + it('includes default variant tag when no rewrite segments', () => { + const tags = collectSitecorePageCacheTags({ + ...base, + personalizedPathname: '/about', + }); + expect(tags.some((t) => t === `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:_default`)).to.equal(true); + }); + + it('uses normalized route segments (strips variant markers from route tag)', () => { + const tags = collectSitecorePageCacheTags({ + ...base, + personalizedPathname: '/about/_variantId_hero-a', + }); + const routeTag = tags.find((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`)); + expect(routeTag).to.equal(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:website:en-us:about`); + }); + + it('includes route, dict, and item tags', () => { + const tags = collectSitecorePageCacheTags({ + ...base, + personalizedPathname: '/about', + }); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:`))).to.equal(true); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:`))).to.equal(true); + expect(tags.some((t) => t.startsWith(`${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:`))).to.equal(true); + }); +}); diff --git a/packages/nextjs/src/cache/sitecore-page-cache-tags.ts b/packages/nextjs/src/cache/sitecore-page-cache-tags.ts new file mode 100644 index 0000000000..b68065f530 --- /dev/null +++ b/packages/nextjs/src/cache/sitecore-page-cache-tags.ts @@ -0,0 +1,71 @@ +import { + getPersonalizedRewriteData, + normalizePersonalizedRewrite, +} from '@sitecore-content-sdk/content/personalize'; +import { + buildSitecoreDictionaryCacheTag, + buildSitecoreItemCacheTagFromRouteData, + buildSitecorePersonalizedPageVariantCacheTag, + buildSitecoreRouteCacheTag, + dedupeSitecoreCacheTags, + type SitecoreRouteDataLike, +} from './sitecore-cache-tags'; + +function normalizePathname(pathname: string): string { + const trimmed = pathname.trim() || '/'; + return trimmed.startsWith('/') ? trimmed : `/${trimmed}`; +} + +/** + * Route segments after removing personalization rewrite markers, for stable route-level tags. + */ +function routeSegmentsFromPersonalizedPathname(personalizedPathname: string): string[] { + const pathname = normalizePathname(personalizedPathname); + const n = normalizePersonalizedRewrite(pathname); + if (!n || n === '/') { + return []; + } + const noLead = n.startsWith('/') ? n.slice(1) : n; + return noLead.split('/').filter(Boolean); +} + +/** + * Inputs for assembling cache tags for a typical Sitecore page render (`getPage`). + * @public + */ +export type CollectSitecorePageCacheTagsParams = { + site: string; + locale: string; + /** + * Path string used for personalization rewrite parsing (`_variantId_...`) and for deriving + * normalized route segments (variants stripped) for the route tag. + */ + personalizedPathname: string; + /** Route node from layout (for item id / language / version). */ + route: SitecoreRouteDataLike; +}; + +/** + * Builds the full tag set for a Sitecore page read: route, dictionary, personalization variant, and route item. + * @param {CollectSitecorePageCacheTagsParams} params - Site, locale, pathname, and route metadata. + * @public + */ +export function collectSitecorePageCacheTags(params: CollectSitecorePageCacheTagsParams): string[] { + const pathname = normalizePathname(params.personalizedPathname); + const personalize = getPersonalizedRewriteData(pathname); + const pathSegments = routeSegmentsFromPersonalizedPathname(pathname); + + return dedupeSitecoreCacheTags([ + buildSitecoreRouteCacheTag({ + site: params.site, + locale: params.locale, + pathSegments, + }), + buildSitecoreDictionaryCacheTag({ site: params.site, locale: params.locale }), + buildSitecorePersonalizedPageVariantCacheTag({ + variantId: personalize.variantId, + componentVariantIds: personalize.componentVariantIds, + }), + buildSitecoreItemCacheTagFromRouteData(params.route, params.locale) ?? '', + ]).filter(Boolean); +} diff --git a/packages/nextjs/src/index.ts b/packages/nextjs/src/index.ts index 65524fe089..6ccfc4f45c 100644 --- a/packages/nextjs/src/index.ts +++ b/packages/nextjs/src/index.ts @@ -178,3 +178,33 @@ export { getCachedPageParams, setCachedPageParams, } from './cache/page-params'; + +export { + SITECORE_CONTENT_CACHE_TAG_PREFIX, + sanitizeSitecoreCacheTagSegment, + normalizeSitecoreItemIdForCacheTag, + buildSitecoreRouteCacheTag, + buildSitecoreItemCacheTag, + buildSitecoreDictionaryCacheTag, + buildSitecorePersonalizedPageVariantCacheTag, + buildSitecoreItemCacheTagFromRouteData, + dedupeSitecoreCacheTags, + type BuildSitecoreRouteCacheTagParams, + type BuildSitecoreItemCacheTagParams, + type BuildSitecoreDictionaryCacheTagParams, + type BuildSitecorePersonalizedPageVariantCacheTagParams, + type SitecoreRouteDataLike, +} from './cache/sitecore-cache-tags'; + +export { + collectSitecorePageCacheTags, + type CollectSitecorePageCacheTagsParams, +} from './cache/sitecore-page-cache-tags'; + +export { + extractSitecoreEdgeContentId, + collectSitecoreTagsFromEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateUpdate, + type SitecoreEdgeRevalidateRequestBody, + type CollectSitecoreTagsFromEdgeBodyOptions, +} from './cache/sitecore-edge-webhook-revalidation'; diff --git a/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.test.ts b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.test.ts new file mode 100644 index 0000000000..a849d85943 --- /dev/null +++ b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.test.ts @@ -0,0 +1,110 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import proxyquire from 'proxyquire'; +const proxyquireNoCallThru = proxyquire.noCallThru(); + +describe('createEdgeWebhookRevalidateRouteHandler', () => { + const sandbox = sinon.createSandbox(); + let revalidateTagStub: sinon.SinonStub; + let nextResponseJsonStub: sinon.SinonStub; + let module: { createEdgeWebhookRevalidateRouteHandler: typeof import('./edge-webhook-revalidate-route-handler').createEdgeWebhookRevalidateRouteHandler }; + + const createReq = (params: { headers?: Record; body?: unknown }) => { + const { headers = {}, body = {} } = params; + return { + headers: new Headers(headers), + json: async () => body, + } as any; + }; + + beforeEach(() => { + revalidateTagStub = sandbox.stub(); + nextResponseJsonStub = sandbox.stub().callsFake((body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + body, + })); + + module = proxyquireNoCallThru('./edge-webhook-revalidate-route-handler', { + 'next/cache': { revalidateTag: revalidateTagStub }, + 'next/server': { NextRequest: class {}, NextResponse: { json: nextResponseJsonStub } }, + }); + }); + + afterEach(() => { + sandbox.restore(); + delete process.env.SITECORE_REVALIDATE_SECRET; + }); + + it('should return 400 when no tags resolve from body', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 's'; + const handler = module.createEdgeWebhookRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 's' }, + body: { updates: [{ identifier: '' }] }, + }) + ); + + expect(res.status).to.equal(400); + expect(revalidateTagStub.called).to.equal(false); + }); + + it('should revalidate tags from updates and echo invocation metadata', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createEdgeWebhookRevalidateRouteHandler({ defaultLocale: 'en' }); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + body: { + invocation_id: 'inv-1', + continues: true, + updates: [ + { + identifier: '71B0BA0716214254AEE4429B1A970C8B-media', + entity_culture: 'en', + }, + ], + }, + }) + ); + + expect(res.status).to.equal(200); + expect(revalidateTagStub.calledOnce).to.equal(true); + expect(revalidateTagStub.firstCall.args[0]).to.equal( + 'sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest' + ); + expect(res.body).to.deep.include({ + revalidated: true, + invocation_id: 'inv-1', + continues: true, + }); + }); + + it('should include additional tags configured in options', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createEdgeWebhookRevalidateRouteHandler({ + defaultLocale: 'en', + additionalTags: () => ['sc:dict:new-testing-site-mn:en'], + }); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + body: { + updates: [ + { + identifier: '71B0BA0716214254AEE4429B1A970C8B', + entity_culture: 'en', + }, + ], + }, + }) + ); + + expect(res.status).to.equal(200); + expect(revalidateTagStub.calledTwice).to.equal(true); + expect(revalidateTagStub.firstCall.args[0]).to.equal( + 'sc:item:71b0ba0716214254aee4429b1a970c8b:en:latest' + ); + expect(revalidateTagStub.secondCall.args[0]).to.equal('sc:dict:new-testing-site-mn:en'); + }); +}); diff --git a/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts new file mode 100644 index 0000000000..b604b779b4 --- /dev/null +++ b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts @@ -0,0 +1,103 @@ +import { + collectSitecoreTagsFromEdgeRevalidateRequestBody, + type SitecoreEdgeRevalidateRequestBody, +} from '../cache/sitecore-edge-webhook-revalidation'; +import { dedupeSitecoreCacheTags } from '../cache/sitecore-cache-tags'; +import { revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; +import type { RevalidateRouteHandlerOptions } from './revalidate-route-handler'; + +const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; +const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; + +export type EdgeWebhookRevalidateRouteHandlerOptions = RevalidateRouteHandlerOptions & { + /** + * Fallback locale for item tags when `entity_culture` is missing, and for bare item ids in `tags`. + * Default is `en`. + */ + defaultLocale?: string; + /** + * Extra tags to always revalidate for each accepted webhook call. + * This is useful for coarse invalidation strategies (e.g. dictionary buckets) when webhook payloads + * do not carry enough information to map a change to deterministic cache tags. + */ + additionalTags?: string[] | ((body: SitecoreEdgeRevalidateRequestBody) => string[]); +}; + +function normalizeAdditionalTags( + additionalTags: EdgeWebhookRevalidateRouteHandlerOptions['additionalTags'], + body: SitecoreEdgeRevalidateRequestBody +): string[] { + const values = typeof additionalTags === 'function' ? additionalTags(body) : additionalTags; + return (values ?? []).map((tag) => tag.trim()).filter(Boolean); +} + +/** + * Creates a POST route handler for Experience Edge / Content Operations style webhook bodies (POC-aligned). + * Maps `updates[].identifier` (+ optional `entity_culture`) to Content SDK **item** cache tags (`sc:item:...`) + * so invalidation matches tags from {@link collectSitecorePageCacheTags}. + * + * Uses the same secret env/header defaults as {@link createRevalidateRouteHandler}. + * @public + */ +export function createEdgeWebhookRevalidateRouteHandler( + options: EdgeWebhookRevalidateRouteHandlerOptions = {} +) { + const { + defaultLocale = 'en', + additionalTags, + secret, + secretEnvVarName = DEFAULT_SECRET_ENV_VAR, + secretHeaderName = DEFAULT_SECRET_HEADER, + cacheProfile = 'max', + } = options; + + const POST = async (req: NextRequest) => { + const configuredSecret = secret ?? process.env[secretEnvVarName]; + if (!configuredSecret) { + return NextResponse.json( + { error: `${secretEnvVarName} is not configured.` }, + { status: 500 } + ); + } + + const providedSecret = req.headers.get(secretHeaderName); + if (providedSecret !== configuredSecret) { + return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + let body: SitecoreEdgeRevalidateRequestBody; + try { + body = (await req.json()) as SitecoreEdgeRevalidateRequestBody; + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON.' }, { status: 400 }); + } + + const tags = dedupeSitecoreCacheTags([ + ...collectSitecoreTagsFromEdgeRevalidateRequestBody(body, { defaultLocale }), + ...normalizeAdditionalTags(additionalTags, body), + ]); + if (tags.length === 0) { + return NextResponse.json( + { + error: + 'Provide non-empty `updates` (with identifiers) and/or `tags` that resolve to at least one cache tag.', + }, + { status: 400 } + ); + } + + for (const tag of tags) { + revalidateTag(tag, cacheProfile); + } + + return NextResponse.json({ + revalidated: true, + tags, + invocation_id: body.invocation_id ?? null, + continues: body.continues ?? false, + }); + }; + + return { POST }; +} diff --git a/packages/nextjs/src/route-handler/index.ts b/packages/nextjs/src/route-handler/index.ts index 53e07b71cd..8c75b345f5 100644 --- a/packages/nextjs/src/route-handler/index.ts +++ b/packages/nextjs/src/route-handler/index.ts @@ -2,4 +2,12 @@ export { createSitemapRouteHandler } from './sitemap-route-handler'; export { createRobotsRouteHandler } from './robots-route-handler'; export { createEditingConfigRouteHandler } from './editing-config-route-handler'; export { createEditingRenderRouteHandlers } from './editing-render-route-handler'; +export { + createRevalidateRouteHandler, + type RevalidateRouteHandlerOptions, +} from './revalidate-route-handler'; +export { + createEdgeWebhookRevalidateRouteHandler, + type EdgeWebhookRevalidateRouteHandlerOptions, +} from './edge-webhook-revalidate-route-handler'; diff --git a/packages/nextjs/src/route-handler/revalidate-route-handler.test.ts b/packages/nextjs/src/route-handler/revalidate-route-handler.test.ts new file mode 100644 index 0000000000..f518118ab1 --- /dev/null +++ b/packages/nextjs/src/route-handler/revalidate-route-handler.test.ts @@ -0,0 +1,157 @@ +import { expect } from 'chai'; +import sinon from 'sinon'; +import proxyquire from 'proxyquire'; +const proxyquireNoCallThru = proxyquire.noCallThru(); + +describe('createRevalidateRouteHandler', () => { + const sandbox = sinon.createSandbox(); + + let revalidateTagStub: sinon.SinonStub; + let nextResponseJsonStub: sinon.SinonStub; + let module: any; + + const createReq = (params: { + headers?: Record; + body?: unknown; + jsonThrows?: boolean; + }) => { + const { headers = {}, body = {}, jsonThrows = false } = params; + return { + headers: new Headers(headers), + json: async () => { + if (jsonThrows) throw new Error('bad-json'); + return body; + }, + } as any; + }; + + beforeEach(() => { + revalidateTagStub = sandbox.stub(); + nextResponseJsonStub = sandbox.stub().callsFake((body: unknown, init?: { status?: number }) => ({ + status: init?.status ?? 200, + body, + })); + + module = proxyquireNoCallThru('./revalidate-route-handler', { + 'next/cache': { revalidateTag: revalidateTagStub }, + 'next/server': { NextRequest: class {}, NextResponse: { json: nextResponseJsonStub } }, + }); + }); + + afterEach(() => { + sandbox.restore(); + delete process.env.SITECORE_REVALIDATE_SECRET; + delete process.env.REVALIDATE_SECRET_TEST; + }); + + it('should return 500 when secret is not configured', async () => { + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST(createReq({})); + + expect(res.status).to.equal(500); + expect(res.body).to.deep.equal({ error: 'SITECORE_REVALIDATE_SECRET is not configured.' }); + }); + + it('should return 401 when secret is invalid', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'invalid' }, + }) + ); + + expect(res.status).to.equal(401); + expect(res.body).to.deep.equal({ error: 'Unauthorized.' }); + }); + + it('should return 400 when request body is invalid JSON', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + jsonThrows: true, + }) + ); + + expect(res.status).to.equal(400); + expect(res.body).to.deep.equal({ error: 'Request body must be valid JSON.' }); + }); + + it('should return 400 when no tags are provided', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + body: {}, + }) + ); + + expect(res.status).to.equal(400); + expect(res.body).to.deep.equal({ + error: 'Provide a non-empty `tag` or `tags` in the request body.', + }); + }); + + it('should revalidate a single tag and return 200', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + body: { tag: 'sc:route:site:en:_' }, + }) + ); + + expect(revalidateTagStub.calledOnceWithExactly('sc:route:site:en:_', 'max')).to.equal(true); + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ + revalidated: true, + tags: ['sc:route:site:en:_'], + }); + }); + + it('should dedupe tags and trim whitespace', async () => { + process.env.SITECORE_REVALIDATE_SECRET = 'expected'; + const handler = module.createRevalidateRouteHandler(); + const res = await handler.POST( + createReq({ + headers: { 'x-revalidate-secret': 'expected' }, + body: { + tags: [' sc:dict:site:en ', 'sc:dict:site:en', 'sc:route:site:en:_'], + }, + }) + ); + + expect(revalidateTagStub.callCount).to.equal(2); + expect(revalidateTagStub.firstCall.args).to.deep.equal(['sc:dict:site:en', 'max']); + expect(revalidateTagStub.secondCall.args).to.deep.equal(['sc:route:site:en:_', 'max']); + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ + revalidated: true, + tags: ['sc:dict:site:en', 'sc:route:site:en:_'], + }); + }); + + it('should use custom secret options', async () => { + process.env.REVALIDATE_SECRET_TEST = 'expected'; + const handler = module.createRevalidateRouteHandler({ + secretEnvVarName: 'REVALIDATE_SECRET_TEST', + secretHeaderName: 'x-custom-secret', + }); + const res = await handler.POST( + createReq({ + headers: { 'x-custom-secret': 'expected' }, + body: { tag: 'sc:route:site:en:_' }, + }) + ); + + expect(res.status).to.equal(200); + expect(res.body).to.deep.equal({ + revalidated: true, + tags: ['sc:route:site:en:_'], + }); + }); +}); diff --git a/packages/nextjs/src/route-handler/revalidate-route-handler.ts b/packages/nextjs/src/route-handler/revalidate-route-handler.ts new file mode 100644 index 0000000000..5154f0e2a0 --- /dev/null +++ b/packages/nextjs/src/route-handler/revalidate-route-handler.ts @@ -0,0 +1,97 @@ +import { dedupeSitecoreCacheTags } from '../cache/sitecore-cache-tags'; +import { revalidateTag } from 'next/cache'; +import { NextRequest, NextResponse } from 'next/server'; + +type RevalidateRequestBody = { + tag?: string; + tags?: string[]; +}; + +export type RevalidateRouteHandlerOptions = { + /** + * Shared secret expected from request headers. + * If omitted, handler uses process.env[secretEnvVarName]. + */ + secret?: string; + /** + * Environment variable name for resolving the shared secret. + * Default is SITECORE_REVALIDATE_SECRET. + */ + secretEnvVarName?: string; + /** + * Request header name used for passing the secret. + * Default is x-revalidate-secret. + */ + secretHeaderName?: string; + /** + * Next.js cache profile used by revalidateTag. + * Default is max. + */ + cacheProfile?: 'max'; +}; + +const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; +const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; + +function normalizeTags(body: RevalidateRequestBody): string[] { + const fromSingle = body.tag ? [body.tag] : []; + const fromMany = body.tags ?? []; + const normalized = [...fromSingle, ...fromMany].map((tag) => tag.trim()).filter(Boolean); + return dedupeSitecoreCacheTags(normalized); +} + +/** + * Creates a route handler for manual/automated tag revalidation. + * @param {RevalidateRouteHandlerOptions} [options] - Handler options. + * @returns The route handler with POST method. + * @public + */ +export function createRevalidateRouteHandler(options: RevalidateRouteHandlerOptions = {}) { + const { + secret, + secretEnvVarName = DEFAULT_SECRET_ENV_VAR, + secretHeaderName = DEFAULT_SECRET_HEADER, + cacheProfile = 'max', + } = options; + + const POST = async (req: NextRequest) => { + const configuredSecret = secret ?? process.env[secretEnvVarName]; + if (!configuredSecret) { + return NextResponse.json( + { error: `${secretEnvVarName} is not configured.` }, + { status: 500 } + ); + } + + const providedSecret = req.headers.get(secretHeaderName); + if (providedSecret !== configuredSecret) { + return NextResponse.json({ error: 'Unauthorized.' }, { status: 401 }); + } + + let body: RevalidateRequestBody; + try { + body = (await req.json()) as RevalidateRequestBody; + } catch { + return NextResponse.json({ error: 'Request body must be valid JSON.' }, { status: 400 }); + } + + const tags = normalizeTags(body); + if (tags.length === 0) { + return NextResponse.json( + { error: 'Provide a non-empty `tag` or `tags` in the request body.' }, + { status: 400 } + ); + } + + for (const tag of tags) { + revalidateTag(tag, cacheProfile); + } + + return NextResponse.json({ + revalidated: true, + tags, + }); + }; + + return { POST }; +} diff --git a/scripts/samples.json b/scripts/samples.json index ea1d610778..9712a27f70 100644 --- a/scripts/samples.json +++ b/scripts/samples.json @@ -5,5 +5,12 @@ "prerender": "SSG", "silent": false } + }, + { + "template": "nextjs-app-router-osr", + "args": { + "prerender": "SSG", + "revalidate": true + } } ] From 847825a3ed49c62be3f39a72e7a543ca0baed638 Mon Sep 17 00:00:00 2001 From: MenKNas Date: Wed, 6 May 2026 14:19:52 +0300 Subject: [PATCH 2/4] Fix api verification issues --- packages/nextjs/api/content-sdk-nextjs.api.md | 134 ++++++++++++++++++ .../nextjs/src/cache/sitecore-cache-tags.ts | 20 +++ .../sitecore-edge-webhook-revalidation.ts | 4 + .../edge-webhook-revalidate-route-handler.ts | 4 + .../route-handler/revalidate-route-handler.ts | 4 + 5 files changed, 166 insertions(+) diff --git a/packages/nextjs/api/content-sdk-nextjs.api.md b/packages/nextjs/api/content-sdk-nextjs.api.md index b5ee7d84cd..0a1afd097c 100644 --- a/packages/nextjs/api/content-sdk-nextjs.api.md +++ b/packages/nextjs/api/content-sdk-nextjs.api.md @@ -233,6 +233,47 @@ export type BotTrackingProxyConfig = SitecoreConfig_2['api']['edge'] & Omit ImportEntry[]; @@ -325,6 +385,18 @@ export { ComponentRendering } export { constants } +// @public +export function createEdgeWebhookRevalidateRouteHandler(options?: EdgeWebhookRevalidateRouteHandlerOptions): { + POST: (req: NextRequest) => Promise | NextResponse<{ + revalidated: boolean; + tags: string[]; + invocation_id: string | null; + continues: boolean; + }>>; +}; + // Warning: (ae-forgotten-export) The symbol "EditingConfigRouteHandlerOptions" needs to be exported by the entry point api-surface.d.ts // // @public @@ -344,6 +416,16 @@ export const createEditingRenderRouteHandlers: (options: EditingHandlerOptions) export { createGraphQLClientFactory } +// @public +export function createRevalidateRouteHandler(options?: RevalidateRouteHandlerOptions): { + POST: (req: NextRequest) => Promise | NextResponse<{ + revalidated: boolean; + tags: string[]; + }>>; +}; + // Warning: (ae-forgotten-export) The symbol "RouteHandlerOptions_2" needs to be exported by the entry point api-surface.d.ts // // @public @@ -364,6 +446,9 @@ export { DateField } const debug_2: Record; export { debug_2 as debug } +// @public +export function dedupeSitecoreCacheTags(tags: string[]): string[]; + export { DefaultEmptyFieldEditingComponentImage } export { DefaultEmptyFieldEditingComponentText } @@ -400,6 +485,12 @@ export { DictionaryService } export { DictionaryServiceConfig } +// @public +export type EdgeWebhookRevalidateRouteHandlerOptions = RevalidateRouteHandlerOptions & { + defaultLocale?: string; + additionalTags?: string[] | ((body: SitecoreEdgeRevalidateRequestBody) => string[]); +}; + export { EDITING_COMPONENT_ID } export { EDITING_COMPONENT_PLACEHOLDER } @@ -457,6 +548,9 @@ export { extractFiles } // @public export const extractPath: (context: GetStaticPropsContext | GetServerSidePropsContext) => string; +// @public +export function extractSitecoreEdgeContentId(identifier: string): string; + export { FEaaSClientWrapper } export { FEaaSComponent } @@ -688,6 +782,9 @@ export type NextjsContentSdkComponent = ReactContentSdkComponent & { export { normalizePersonalizedRewrite } +// @public +export function normalizeSitecoreItemIdForCacheTag(itemId: string): string; + export { normalizeSiteRewrite } export { Page } @@ -878,6 +975,14 @@ export { resolveUrl } export { RetryStrategy } +// @public +export type RevalidateRouteHandlerOptions = { + secret?: string; + secretEnvVarName?: string; + secretHeaderName?: string; + cacheProfile?: 'max'; +}; + // @public export const RichText: { (props: RichTextProps): JSX_2.Element; @@ -907,9 +1012,15 @@ export { RobotsServiceConfig } export { RouteData } +// @public +export function sanitizeSitecoreCacheTagSegment(value: string): string; + // @public export function setCachedPageParams(pageParams: CachedPageParams): void; +// @public +export const SITECORE_CONTENT_CACHE_TAG_PREFIX = "sc"; + // @public export class SitecoreClient extends SitecoreClient_2 { constructor(initOptions: SitecoreNextjsClientInit); @@ -945,6 +1056,22 @@ export type SitecoreConfigInput = SitecoreConfigInput_2 & { sitecoreInternalEditingHostUrl?: string; }; +// @public +export type SitecoreEdgeRevalidateRequestBody = { + invocation_id?: string; + updates?: SitecoreEdgeRevalidateUpdate[]; + continues?: boolean; + tags?: string[]; +}; + +// @public +export type SitecoreEdgeRevalidateUpdate = { + identifier?: string; + entity_definition?: string; + operation?: string; + entity_culture?: string; +}; + // @public export type SitecorePageProps = { page: Page | null; @@ -959,6 +1086,13 @@ export { SitecoreProviderReactContext } export { SitecoreProviderState } +// @public +export type SitecoreRouteDataLike = { + itemId?: string; + itemLanguage?: string; + itemVersion?: number; +}; + export { SiteInfo } export { SiteInfoService } diff --git a/packages/nextjs/src/cache/sitecore-cache-tags.ts b/packages/nextjs/src/cache/sitecore-cache-tags.ts index 3e05a7405d..a0063d6f61 100644 --- a/packages/nextjs/src/cache/sitecore-cache-tags.ts +++ b/packages/nextjs/src/cache/sitecore-cache-tags.ts @@ -24,6 +24,10 @@ export function normalizeSitecoreItemIdForCacheTag(itemId: string): string { return itemId.trim().toLowerCase().replace(/[{}]/g, ''); } +/** + * Parameters for {@link buildSitecoreRouteCacheTag}. + * @public + */ export type BuildSitecoreRouteCacheTagParams = { site: string; locale: string; @@ -47,6 +51,10 @@ export function buildSitecoreRouteCacheTag(params: BuildSitecoreRouteCacheTagPar return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:route:${site}:${locale}:${pathKey}`; } +/** + * Parameters for {@link buildSitecoreItemCacheTag}. + * @public + */ export type BuildSitecoreItemCacheTagParams = { itemId: string; locale: string; @@ -71,6 +79,10 @@ export function buildSitecoreItemCacheTag(params: BuildSitecoreItemCacheTagParam return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:item:${id}:${locale}:${ver}`; } +/** + * Parameters for {@link buildSitecoreDictionaryCacheTag}. + * @public + */ export type BuildSitecoreDictionaryCacheTagParams = { site: string; locale: string; @@ -87,6 +99,10 @@ export function buildSitecoreDictionaryCacheTag(params: BuildSitecoreDictionaryC return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:dict:${site}:${locale}`; } +/** + * Parameters for {@link buildSitecorePersonalizedPageVariantCacheTag}. + * @public + */ export type BuildSitecorePersonalizedPageVariantCacheTagParams = { /** * Primary personalization variant id from routing / `PageOptions.personalize`. @@ -115,6 +131,10 @@ export function buildSitecorePersonalizedPageVariantCacheTag( return `${SITECORE_CONTENT_CACHE_TAG_PREFIX}:pvv:${variant}${suffix}`; } +/** + * Minimal route data shape for building item cache tags from layout responses. + * @public + */ export type SitecoreRouteDataLike = { itemId?: string; itemLanguage?: string; diff --git a/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts index 06e6734dd4..51459cbeab 100644 --- a/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts +++ b/packages/nextjs/src/cache/sitecore-edge-webhook-revalidation.ts @@ -52,6 +52,10 @@ function isFullSitecoreContentCacheTag(value: string): boolean { return value.startsWith(FULL_TAG_PREFIX); } +/** + * Options for {@link collectSitecoreTagsFromEdgeRevalidateRequestBody}. + * @public + */ export type CollectSitecoreTagsFromEdgeBodyOptions = { /** * Used when an update omits `entity_culture`, and when mapping bare item ids in `tags`. diff --git a/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts index b604b779b4..61b764aab4 100644 --- a/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts +++ b/packages/nextjs/src/route-handler/edge-webhook-revalidate-route-handler.ts @@ -10,6 +10,10 @@ import type { RevalidateRouteHandlerOptions } from './revalidate-route-handler'; const DEFAULT_SECRET_ENV_VAR = 'SITECORE_REVALIDATE_SECRET'; const DEFAULT_SECRET_HEADER = 'x-revalidate-secret'; +/** + * Options for {@link createEdgeWebhookRevalidateRouteHandler}. + * @public + */ export type EdgeWebhookRevalidateRouteHandlerOptions = RevalidateRouteHandlerOptions & { /** * Fallback locale for item tags when `entity_culture` is missing, and for bare item ids in `tags`. diff --git a/packages/nextjs/src/route-handler/revalidate-route-handler.ts b/packages/nextjs/src/route-handler/revalidate-route-handler.ts index 5154f0e2a0..0c4f8cf986 100644 --- a/packages/nextjs/src/route-handler/revalidate-route-handler.ts +++ b/packages/nextjs/src/route-handler/revalidate-route-handler.ts @@ -7,6 +7,10 @@ type RevalidateRequestBody = { tags?: string[]; }; +/** + * Options for {@link createRevalidateRouteHandler}. + * @public + */ export type RevalidateRouteHandlerOptions = { /** * Shared secret expected from request headers. From fc39bddb4f0d20b8f6149d569ea8c87e63a238f7 Mon Sep 17 00:00:00 2001 From: MenKNas Date: Fri, 8 May 2026 11:49:52 +0300 Subject: [PATCH 3/4] Restore back dictionary caching time --- .../templates/nextjs-app-router-osr/sitecore.config.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts index 24de77be3d..3949415353 100644 --- a/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts +++ b/packages/create-content-sdk-app/src/templates/nextjs-app-router-osr/sitecore.config.ts @@ -4,4 +4,11 @@ import { defineConfig } from '@sitecore-content-sdk/nextjs/config'; * See the documentation for `defineConfig`: * https://doc.sitecore.com/xmc/en/developers/content-sdk/the-sitecore-configuration-file.html */ -export default defineConfig({}); +export default defineConfig({ + dictionary: { + caching: { + enabled: true, + timeout: 60, + }, + }, +}); From a360153b720066bac899789b9a5703921e1f7c24 Mon Sep 17 00:00:00 2001 From: MenKNas Date: Fri, 8 May 2026 11:59:02 +0300 Subject: [PATCH 4/4] Add Changeset entry --- .changeset/slow-lamps-learn.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/slow-lamps-learn.md diff --git a/.changeset/slow-lamps-learn.md b/.changeset/slow-lamps-learn.md new file mode 100644 index 0000000000..1a9b4e0df2 --- /dev/null +++ b/.changeset/slow-lamps-learn.md @@ -0,0 +1,8 @@ +--- +"@sitecore-content-sdk/nextjs": minor +"create-content-sdk-app": minor +--- + +Add tag-based revalidation support for App Router OSR, including cache tag helpers and revalidation route handlers. + +Introduce the `nextjs-app-router-osr` scaffolding template with manual and webhook revalidation routes wired out of the box.