From ac2ed99871cc6d79f748cfd663f6ac41ad462e25 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 26 Nov 2025 10:44:58 +0100 Subject: [PATCH 01/41] add domain as value for the redirects enum --- demo/api/schema.gql | 1 + packages/api/cms-api/schema.gql | 1 + .../mikro-orm/migrations/Migration20251126093305.ts | 10 ++++++++++ packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts | 2 ++ packages/api/cms-api/src/redirects/redirects.enum.ts | 1 + 5 files changed, 15 insertions(+) create mode 100644 packages/api/cms-api/src/mikro-orm/migrations/Migration20251126093305.ts diff --git a/demo/api/schema.gql b/demo/api/schema.gql index dc35302a08b..9e6e413914a 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -1715,6 +1715,7 @@ enum RedirectSortField { } enum RedirectSourceTypeValues { + domain path } diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index ac0e5aa824d..dce28d65d71 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -356,6 +356,7 @@ type Redirect { enum RedirectSourceTypeValues { path + domain } enum RedirectGenerationType { diff --git a/packages/api/cms-api/src/mikro-orm/migrations/Migration20251126093305.ts b/packages/api/cms-api/src/mikro-orm/migrations/Migration20251126093305.ts new file mode 100644 index 00000000000..800b6084738 --- /dev/null +++ b/packages/api/cms-api/src/mikro-orm/migrations/Migration20251126093305.ts @@ -0,0 +1,10 @@ +import { Migration } from "@mikro-orm/migrations"; + +export class Migration20251126093305 extends Migration { + override async up(): Promise { + this.addSql(`ALTER TABLE "Redirect" DROP CONSTRAINT IF EXISTS "Redirect_sourceType_check";`); + this.addSql(`ALTER TABLE "Redirect" ADD CONSTRAINT "Redirect_sourceType_check" CHECK ("sourceType" IN ('path', 'domain'));`); + } + + override async down(): Promise {} +} diff --git a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts index 025cc436e6a..4a0a291ea6d 100644 --- a/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts +++ b/packages/api/cms-api/src/mikro-orm/mikro-orm.module.ts @@ -34,6 +34,7 @@ import { Migration20250623085054 } from "./migrations/Migration20250623085054"; import { Migration20250623113026 } from "./migrations/Migration20250623113026"; import { Migration20251013081751 } from "./migrations/Migration20251013081751"; import { Migration20251118143418 } from "./migrations/Migration20251118143418"; +import { Migration20251126093305 } from "./migrations/Migration20251126093305"; export interface MikroOrmModuleOptions { ormConfig: MikroOrmNestjsOptions; @@ -103,6 +104,7 @@ export function createOrmConfig({ migrations, ...defaults }: MikroOrmNestjsOptio { name: "Migration20250623113026", class: Migration20250623113026 }, { name: "Migration20251118143418", class: Migration20251118143418 }, { name: "Migration20251013081751", class: Migration20251013081751 }, + { name: "Migration20251126093305", class: Migration20251126093305 }, ...(migrations?.migrationsList || []), ].sort((migrationA, migrationB) => { if (migrationA.name < migrationB.name) { diff --git a/packages/api/cms-api/src/redirects/redirects.enum.ts b/packages/api/cms-api/src/redirects/redirects.enum.ts index 5bc244f62b3..7de3bf7102b 100644 --- a/packages/api/cms-api/src/redirects/redirects.enum.ts +++ b/packages/api/cms-api/src/redirects/redirects.enum.ts @@ -2,6 +2,7 @@ import { registerEnumType } from "@nestjs/graphql"; export enum RedirectSourceTypeValues { "path" = "path", + "domain" = "domain", } registerEnumType(RedirectSourceTypeValues, { name: "RedirectSourceTypeValues" }); From 4c3de38ff0e1e7b9eac9d96874ff479a65449cfb Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 26 Nov 2025 10:45:27 +0100 Subject: [PATCH 02/41] add domain as sourceTypeOptions in the redirect form in admin --- packages/admin/cms-admin/src/redirects/RedirectForm.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index 9140b413519..9d81dc671c0 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -97,6 +97,13 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element defaultMessage: "Path", }), }, + { + value: "domain", + label: intl.formatMessage({ + id: "comet.pages.redirects.redirect.source.type.domain", + defaultMessage: "Domain", + }), + }, ]; const [submit] = useSubmitMutation(mode, id, linkBlock, scope); From 6f5d73dfa5c2dc4427550e14df6d031dd68e367b Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 26 Nov 2025 10:52:32 +0100 Subject: [PATCH 03/41] set placeholder depending on selected source type --- packages/admin/cms-admin/src/redirects/RedirectForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index 9d81dc671c0..3bbf045b3c8 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -226,7 +226,7 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element // eslint-disable-next-line @typescript-eslint/no-explicit-any validate={validateSource as any} fullWidth - placeholder="/example-path" + placeholder={values.sourceType === "domain" ? "https://example.com" : "/example-path"} disableContentTranslation /> Date: Wed, 26 Nov 2025 11:25:25 +0100 Subject: [PATCH 04/41] add info box if domain is selected --- .../admin/cms-admin/src/redirects/RedirectForm.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index 3bbf045b3c8..00a443e7b05 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -1,5 +1,6 @@ import { gql, useApolloClient, useQuery } from "@apollo/client"; import { + Alert, Field, FillSpace, FinalForm, @@ -14,7 +15,7 @@ import { ToolbarTitleItem, useStackSwitchApi, } from "@comet/admin"; -import { MenuItem } from "@mui/material"; +import { Box, MenuItem } from "@mui/material"; import isEqual from "lodash.isequal"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; @@ -216,6 +217,16 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element )} + {values.sourceType === "domain" && ( + + + + + + )} Date: Wed, 17 Dec 2025 14:24:47 +0100 Subject: [PATCH 05/41] handle domain redirects in middleware --- demo/site-configs/main.ts | 1 + .../site/src/middleware/redirectToMainHost.ts | 91 +++++++++++++++++-- 2 files changed, 82 insertions(+), 10 deletions(-) diff --git a/demo/site-configs/main.ts b/demo/site-configs/main.ts index a55d93d9878..d874345c4d8 100644 --- a/demo/site-configs/main.ts +++ b/demo/site-configs/main.ts @@ -9,6 +9,7 @@ export default ((env) => { name: "Comet Site Main", domains: { main: envToDomainMap[env], + additional: ["test.localhost:3000"], }, public: { scope: { diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 20845be12a0..af7a60b5f3e 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,14 +1,61 @@ +import { gql } from "@comet/site-nextjs"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import type { PublicSiteConfig } from "@src/site-configs"; +import { createSitePath } from "@src/util/createSitePath"; +import { createGraphQLFetch } from "@src/util/graphQLClient"; import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; import { type NextRequest, NextResponse } from "next/server"; +import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; -const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); +const domainRedirectsQuery = gql` + query DomainRedirects($scope: RedirectScopeInput!) { + paginatedRedirects(scope: $scope) { + nodes { + id + source + target + sourceType + } + } + } +`; + +async function fetchDomainRedirects(domain: string) { + const key = `domainRedirects-${domain}`; + return memoryCache.wrap(key, async () => { + const graphQLFetch = createGraphQLFetch(); + const data = await graphQLFetch< + { paginatedRedirects: { nodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[] } }, + { scope: { domain: string } } + >(domainRedirectsQuery, { + scope: { domain }, + }); + + return data?.paginatedRedirects?.nodes || []; + }); +} + +async function getDomainRedirectTarget(domain: string, host: string): Promise { + const redirects = await fetchDomainRedirects(domain); + const redirectsArray = Array.isArray(redirects) ? redirects : [redirects]; + const normalizeHost = (value: string) => { + return value.replace(/^https?:\/\//, ""); + }; + const matching = redirectsArray.find((redirct) => { + return redirct.sourceType === "domain" && normalizeHost(redirct.source) === normalizeHost(host); + }); + if (matching) { + return matching.target; + } + return undefined; +} const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { - if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) return true; // non-www redirect - if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) return true; + if (siteConfig.domains.main === host) return true; + if (siteConfig.domains.additional?.includes(host)) return true; return false; }; @@ -17,9 +64,6 @@ const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { return new RegExp(siteConfig.domains.pattern).test(host); }; -/** - * When http host isn't siteConfig.domains.main (instead .pattern or .additional match), redirect to main host. - */ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { return async (request: NextRequest) => { const headers = request.headers; @@ -27,14 +71,41 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const siteConfig = await getSiteConfigForHost(host); if (!siteConfig) { - // Redirect to Main Host const redirectSiteConfig = getSiteConfigs().find((siteConfig) => matchesHostWithAdditionalDomain(siteConfig, host)) || getSiteConfigs().find((siteConfig) => matchesHostWithPattern(siteConfig, host)); + if (redirectSiteConfig) { - return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { - status: 301, - }); + const { scope } = redirectSiteConfig; + + const domainRedirectTarget = await getDomainRedirectTarget(scope.domain, host); + + if (domainRedirectTarget) { + let destination: string | undefined; + if (typeof domainRedirectTarget === "object" && domainRedirectTarget.block !== undefined) { + switch (domainRedirectTarget.block.type) { + case "internal": { + const internalLink = domainRedirectTarget.block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as Pick, + }); + if (destination && destination.startsWith("/")) { + destination = `http://${host}${destination}`; + } + } + break; + } + case "external": + destination = (domainRedirectTarget.block.props as ExternalLinkBlockData).targetUrl; + break; + } + } + if (destination) { + return NextResponse.redirect(destination, { status: 301 }); + } + } } return NextResponse.json({ error: `Cannot resolve domain: ${host}` }, { status: 404 }); From 9fbd0d270e6625cc7a99edb2503fe542af2e61b9 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 18 Dec 2025 09:44:10 +0100 Subject: [PATCH 06/41] add changeset --- .changeset/swift-monkeys-turn.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/swift-monkeys-turn.md diff --git a/.changeset/swift-monkeys-turn.md b/.changeset/swift-monkeys-turn.md new file mode 100644 index 00000000000..6a1b374d2b9 --- /dev/null +++ b/.changeset/swift-monkeys-turn.md @@ -0,0 +1,8 @@ +--- +"@comet/cms-admin": major +"@comet/cms-api": major +--- + +The redirect form now allows selecting `domain` as a source type + +To fully support domain redirects, additional handling is required in the site middleware. From 6517ee3275310a5e9e4d044b8be4085cac7fe8aa Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 18 Dec 2025 10:03:26 +0100 Subject: [PATCH 07/41] add migration guide --- .../migration-from-v8-to-v9.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index 92dd68bb664..2033ad2bf5e 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -217,6 +217,171 @@ If you're using Knip, you may need to add `proxy.ts` as entry point: ::: +### Domain Redirects + +Domain redirects can now be set in the admin. To enable domain-based redirects, you need to: + +1. **Add the relevant domains** to the `additional` array in your site config, so that the middleware can recognize and handle them. + +2. **Adapt your middleware** to handle the new `domain` source type for redirects. Check for domain-based redirects and perform the appropriate redirect by updating your previous `redirectToMainHost` middleware. + +#### Example: Site Config + +```ts title="site-configs/main.ts" +export default ((env) => { + return { + //... + domains: { + main: envToDomainMap[env], + additional: ["test.localhost:3000"], // Add your additional domains here + }, + //... + }; +}) satisfies GetSiteConfig; +``` + +#### Example: Middleware Usage + +Update your middleware—most likely the `redirectToMainHost` middleware—to handle domain redirects. For example: + +```ts title="site/src/middleware/redirectToMainHost.ts" +const domainRedirectsQuery = gql` + query DomainRedirects($scope: RedirectScopeInput!) { + paginatedRedirects(scope: $scope) { + nodes { + id + source + target + sourceType + } + } + } +`; + +async function fetchDomainRedirects(domain: string) { + const key = `domainRedirects-${domain}`; + return memoryCache.wrap(key, async () => { + const graphQLFetch = createGraphQLFetch(); + const data = await graphQLFetch< + { + paginatedRedirects: { + nodes: { + id: string; + source: string; + target: RedirectsLinkBlockData; + sourceType: string; + }[]; + }; + }, + { scope: { domain: string } } + >(domainRedirectsQuery, { + scope: { domain }, + }); + + return data?.paginatedRedirects?.nodes || []; + }); +} + +async function getDomainRedirectTarget( + domain: string, + host: string, +): Promise { + const redirects = await fetchDomainRedirects(domain); + const redirectsArray = Array.isArray(redirects) ? redirects : [redirects]; + const normalizeHost = (value: string) => { + return value.replace(/^https?:\/\//, ""); + }; + const matching = redirectsArray.find((redirect) => { + return ( + redirect.sourceType === "domain" && + normalizeHost(redirect.source) === normalizeHost(host) + ); + }); + if (matching) { + return matching.target; + } + return undefined; +} + +const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { + if (siteConfig.domains.main === host) return true; + if (siteConfig.domains.additional?.includes(host)) return true; + return false; +}; + +const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { + if (!siteConfig.domains.pattern) return false; + return new RegExp(siteConfig.domains.pattern).test(host); +}; + +export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { + return async (request: NextRequest) => { + const headers = request.headers; + const host = getHostByHeaders(headers); + const siteConfig = await getSiteConfigForHost(host); + + if (!siteConfig) { + const redirectSiteConfig = + getSiteConfigs().find((siteConfig) => + matchesHostWithAdditionalDomain(siteConfig, host), + ) || + getSiteConfigs().find((siteConfig) => matchesHostWithPattern(siteConfig, host)); + + if (redirectSiteConfig) { + const { scope } = redirectSiteConfig; + + const domainRedirectTarget = await getDomainRedirectTarget(scope.domain, host); + + if (domainRedirectTarget) { + let destination: string | undefined; + if ( + typeof domainRedirectTarget === "object" && + domainRedirectTarget.block !== undefined + ) { + switch (domainRedirectTarget.block.type) { + case "internal": { + const internalLink = domainRedirectTarget.block + .props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as Pick< + GQLPageTreeNodeScope, + "language" + >, + }); + if (destination && destination.startsWith("/")) { + destination = `http://${host}${destination}`; + } + } + break; + } + case "external": + destination = ( + domainRedirectTarget.block.props as ExternalLinkBlockData + ).targetUrl; + break; + } + } + if (destination) { + return NextResponse.redirect(destination, { status: 301 }); + } + } + } + + return NextResponse.json({ error: `Cannot resolve domain: ${host}` }, { status: 404 }); + } + return middleware(request); + }; +} +``` + +#### Admin UI + +In the admin, you can now select `domain` as the source type when creating a redirect. Enter the full domain (e.g., `https://mydomain.com`) as the source, and specify the target as usual. + +**Note:** Domain redirects only work if the DNS entry for the domain points to your web server. + ### Add `cache: "force-cache"` to GraphQL fetch Next.js no longer caches `fetch` requests by default. From 49b4f972e5add2d1811d92bb88dcedf220363401 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 18 Dec 2025 10:03:57 +0100 Subject: [PATCH 08/41] fix spelling --- demo/site/src/middleware/redirectToMainHost.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index af7a60b5f3e..cfae65f25b0 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -44,8 +44,8 @@ async function getDomainRedirectTarget(domain: string, host: string): Promise { return value.replace(/^https?:\/\//, ""); }; - const matching = redirectsArray.find((redirct) => { - return redirct.sourceType === "domain" && normalizeHost(redirct.source) === normalizeHost(host); + const matching = redirectsArray.find((redirect) => { + return redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host); }); if (matching) { return matching.target; From 51f4ab31d98772794e3ac733f34e048a35960843 Mon Sep 17 00:00:00 2001 From: juliawegmayr <109900447+juliawegmayr@users.noreply.github.com> Date: Mon, 22 Dec 2025 10:25:26 +0100 Subject: [PATCH 09/41] improve changeset Co-authored-by: Johannes Obermair <48853629+johnnyomair@users.noreply.github.com> --- .changeset/swift-monkeys-turn.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/swift-monkeys-turn.md b/.changeset/swift-monkeys-turn.md index 6a1b374d2b9..db641fce445 100644 --- a/.changeset/swift-monkeys-turn.md +++ b/.changeset/swift-monkeys-turn.md @@ -3,6 +3,6 @@ "@comet/cms-api": major --- -The redirect form now allows selecting `domain` as a source type +Redirects: add `domain` source type To fully support domain redirects, additional handling is required in the site middleware. From ec8db1c1bfaa4fbc55c10d32b05beb3ed90cfdac Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 22 Dec 2025 10:31:13 +0100 Subject: [PATCH 10/41] add link to migration guide instead of code example --- .../migration-from-v8-to-v9.md | 134 +----------------- 1 file changed, 1 insertion(+), 133 deletions(-) diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index 2033ad2bf5e..fa43e7ed4d4 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -242,139 +242,7 @@ export default ((env) => { #### Example: Middleware Usage -Update your middleware—most likely the `redirectToMainHost` middleware—to handle domain redirects. For example: - -```ts title="site/src/middleware/redirectToMainHost.ts" -const domainRedirectsQuery = gql` - query DomainRedirects($scope: RedirectScopeInput!) { - paginatedRedirects(scope: $scope) { - nodes { - id - source - target - sourceType - } - } - } -`; - -async function fetchDomainRedirects(domain: string) { - const key = `domainRedirects-${domain}`; - return memoryCache.wrap(key, async () => { - const graphQLFetch = createGraphQLFetch(); - const data = await graphQLFetch< - { - paginatedRedirects: { - nodes: { - id: string; - source: string; - target: RedirectsLinkBlockData; - sourceType: string; - }[]; - }; - }, - { scope: { domain: string } } - >(domainRedirectsQuery, { - scope: { domain }, - }); - - return data?.paginatedRedirects?.nodes || []; - }); -} - -async function getDomainRedirectTarget( - domain: string, - host: string, -): Promise { - const redirects = await fetchDomainRedirects(domain); - const redirectsArray = Array.isArray(redirects) ? redirects : [redirects]; - const normalizeHost = (value: string) => { - return value.replace(/^https?:\/\//, ""); - }; - const matching = redirectsArray.find((redirect) => { - return ( - redirect.sourceType === "domain" && - normalizeHost(redirect.source) === normalizeHost(host) - ); - }); - if (matching) { - return matching.target; - } - return undefined; -} - -const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { - if (siteConfig.domains.main === host) return true; - if (siteConfig.domains.additional?.includes(host)) return true; - return false; -}; - -const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { - if (!siteConfig.domains.pattern) return false; - return new RegExp(siteConfig.domains.pattern).test(host); -}; - -export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { - return async (request: NextRequest) => { - const headers = request.headers; - const host = getHostByHeaders(headers); - const siteConfig = await getSiteConfigForHost(host); - - if (!siteConfig) { - const redirectSiteConfig = - getSiteConfigs().find((siteConfig) => - matchesHostWithAdditionalDomain(siteConfig, host), - ) || - getSiteConfigs().find((siteConfig) => matchesHostWithPattern(siteConfig, host)); - - if (redirectSiteConfig) { - const { scope } = redirectSiteConfig; - - const domainRedirectTarget = await getDomainRedirectTarget(scope.domain, host); - - if (domainRedirectTarget) { - let destination: string | undefined; - if ( - typeof domainRedirectTarget === "object" && - domainRedirectTarget.block !== undefined - ) { - switch (domainRedirectTarget.block.type) { - case "internal": { - const internalLink = domainRedirectTarget.block - .props as InternalLinkBlockData; - if (internalLink.targetPage) { - destination = createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as Pick< - GQLPageTreeNodeScope, - "language" - >, - }); - if (destination && destination.startsWith("/")) { - destination = `http://${host}${destination}`; - } - } - break; - } - case "external": - destination = ( - domainRedirectTarget.block.props as ExternalLinkBlockData - ).targetUrl; - break; - } - } - if (destination) { - return NextResponse.redirect(destination, { status: 301 }); - } - } - } - - return NextResponse.json({ error: `Cannot resolve domain: ${host}` }, { status: 404 }); - } - return middleware(request); - }; -} -``` +Update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts #### Admin UI From 54fc79d02eefee6722ff5674f1c3ed07210c21d2 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 22 Dec 2025 10:37:27 +0100 Subject: [PATCH 11/41] re-add host normalization --- demo/site/src/middleware/redirectToMainHost.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index cfae65f25b0..c4180f44f93 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -53,9 +53,11 @@ async function getDomainRedirectTarget(domain: string, host: string): Promise (host.startsWith("www.") ? host.substring(4) : host); + const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { - if (siteConfig.domains.main === host) return true; - if (siteConfig.domains.additional?.includes(host)) return true; + if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) return true; // non-www redirect + if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) return true; return false; }; From 0431db3974cf2355eb7fe2ed0cd4be138f914b08 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 22 Dec 2025 10:54:09 +0100 Subject: [PATCH 12/41] add pagination handling --- .../site/src/middleware/redirectToMainHost.ts | 36 ++++++++++++++----- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index c4180f44f93..51b1c926981 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -11,14 +11,15 @@ import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; const domainRedirectsQuery = gql` - query DomainRedirects($scope: RedirectScopeInput!) { - paginatedRedirects(scope: $scope) { + query DomainRedirects($scope: RedirectScopeInput!, $offset: Int, $limit: Int) { + paginatedRedirects(scope: $scope, offset: $offset, limit: $limit) { nodes { id source target sourceType } + totalCount } } `; @@ -27,14 +28,31 @@ async function fetchDomainRedirects(domain: string) { const key = `domainRedirects-${domain}`; return memoryCache.wrap(key, async () => { const graphQLFetch = createGraphQLFetch(); - const data = await graphQLFetch< - { paginatedRedirects: { nodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[] } }, - { scope: { domain: string } } - >(domainRedirectsQuery, { - scope: { domain }, - }); + const limit = 50; + let totalCount = 0; + let currentCount = 0; + let allNodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[] = []; - return data?.paginatedRedirects?.nodes || []; + do { + const data = await graphQLFetch< + { + paginatedRedirects: { + nodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[]; + totalCount: number; + }; + }, + { scope: { domain: string }; offset: number; limit: number } + >(domainRedirectsQuery, { + scope: { domain }, + offset: currentCount, + limit, + }); + const nodes = data?.paginatedRedirects?.nodes || []; + totalCount = data?.paginatedRedirects?.totalCount || 0; + currentCount += nodes.length; + allNodes = allNodes.concat(nodes); + } while (currentCount < totalCount); + return allNodes; }); } From 8a0447035b9b441f5e987bef7f801c1025f51988 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 22 Dec 2025 11:05:04 +0100 Subject: [PATCH 13/41] create and use getRedirectTargetUrl helper --- .../[domain]/[language]/[[...path]]/page.tsx | 32 +++++-------------- .../site/src/middleware/redirectToMainHost.ts | 24 ++------------ demo/site/src/util/getRedirectTargetUrl.ts | 27 ++++++++++++++++ 3 files changed, 38 insertions(+), 45 deletions(-) create mode 100644 demo/site/src/util/getRedirectTargetUrl.ts diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx index 9178e9be3ff..8ab24fb3e55 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx @@ -1,11 +1,10 @@ export const dynamic = "error"; import { gql } from "@comet/site-nextjs"; -import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; import { documentTypes } from "@src/documents"; -import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { type VisibilityParam } from "@src/middleware/domainRewrite"; -import { createSitePath } from "@src/util/createSitePath"; +import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import { setVisibilityParam } from "@src/util/ServerContext"; import { getSiteConfigForDomain } from "@src/util/siteConfig"; @@ -69,30 +68,15 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain] const target = data.redirectBySource?.target as RedirectsLinkBlockData; let destination: string | undefined; if (target.block !== undefined) { - switch (target.block.type) { - case "internal": { - const internalLink = target.block.props as InternalLinkBlockData; - if (internalLink.targetPage) { - destination = createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, - }); - } - break; - } - case "external": - destination = (target.block.props as ExternalLinkBlockData).targetUrl; - break; - case "news": { - const newsLink = target.block.props as NewsLinkBlockData; - if (newsLink.news) { - destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; - } - break; + if (target.block.type === "news") { + const newsLink = target.block.props as NewsLinkBlockData; + if (newsLink.news) { + destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; } + } else { + destination = getRedirectTargetUrl(target.block, domain); } } - if (destination) { redirect(destination); } diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 51b1c926981..e8a8879d3f1 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,8 +1,7 @@ import { gql } from "@comet/site-nextjs"; -import { type ExternalLinkBlockData, type InternalLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; -import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; +import { type RedirectsLinkBlockData } from "@src/blocks.generated"; import type { PublicSiteConfig } from "@src/site-configs"; -import { createSitePath } from "@src/util/createSitePath"; +import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; import { type NextRequest, NextResponse } from "next/server"; @@ -103,24 +102,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (domainRedirectTarget) { let destination: string | undefined; if (typeof domainRedirectTarget === "object" && domainRedirectTarget.block !== undefined) { - switch (domainRedirectTarget.block.type) { - case "internal": { - const internalLink = domainRedirectTarget.block.props as InternalLinkBlockData; - if (internalLink.targetPage) { - destination = createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as Pick, - }); - if (destination && destination.startsWith("/")) { - destination = `http://${host}${destination}`; - } - } - break; - } - case "external": - destination = (domainRedirectTarget.block.props as ExternalLinkBlockData).targetUrl; - break; - } + destination = getRedirectTargetUrl(domainRedirectTarget.block, host); } if (destination) { return NextResponse.redirect(destination, { status: 301 }); diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts new file mode 100644 index 00000000000..354997014a3 --- /dev/null +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -0,0 +1,27 @@ +import { type ExternalLinkBlockData, type InternalLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; + +import { createSitePath } from "./createSitePath"; + +export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], host: string): string | undefined { + if (!block) return undefined; + switch (block.type) { + case "internal": { + const internalLink = block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + let destination = createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as Pick, + }); + if (destination && destination.startsWith("/")) { + destination = `http://${host}${destination}`; + } + return destination; + } + break; + } + case "external": + return (block.props as ExternalLinkBlockData).targetUrl; + } + return undefined; +} From 16e2a4cf51d5575711be6aa303b20ceffc9ffcf3 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 22 Dec 2025 11:25:36 +0100 Subject: [PATCH 14/41] extract functions for single responsability --- .../site/src/middleware/redirectToMainHost.ts | 29 ++++++++++--------- 1 file changed, 15 insertions(+), 14 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index e8a8879d3f1..ff93602a5f5 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -9,6 +9,8 @@ import { type NextRequest, NextResponse } from "next/server"; import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; +type Redirect = { source: string; target: RedirectsLinkBlockData; sourceType: string }; + const domainRedirectsQuery = gql` query DomainRedirects($scope: RedirectScopeInput!, $offset: Int, $limit: Int) { paginatedRedirects(scope: $scope, offset: $offset, limit: $limit) { @@ -23,20 +25,20 @@ const domainRedirectsQuery = gql` } `; -async function fetchDomainRedirects(domain: string) { +async function fetchDomainRedirects(domain: string): Promise { const key = `domainRedirects-${domain}`; return memoryCache.wrap(key, async () => { const graphQLFetch = createGraphQLFetch(); const limit = 50; let totalCount = 0; let currentCount = 0; - let allNodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[] = []; + let allNodes: Redirect[] = []; do { const data = await graphQLFetch< { paginatedRedirects: { - nodes: { id: string; source: string; target: RedirectsLinkBlockData; sourceType: string }[]; + nodes: Redirect[]; totalCount: number; }; }, @@ -55,19 +57,18 @@ async function fetchDomainRedirects(domain: string) { }); } +function normalizeHost(value: string): string { + return value.replace(/^https?:\/\//, ""); +} + +function findDomainRedirectTarget(redirects: Redirect[], host: string): RedirectsLinkBlockData | undefined { + const matching = redirects.find((redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host)); + return matching ? matching.target : undefined; +} + async function getDomainRedirectTarget(domain: string, host: string): Promise { const redirects = await fetchDomainRedirects(domain); - const redirectsArray = Array.isArray(redirects) ? redirects : [redirects]; - const normalizeHost = (value: string) => { - return value.replace(/^https?:\/\//, ""); - }; - const matching = redirectsArray.find((redirect) => { - return redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host); - }); - if (matching) { - return matching.target; - } - return undefined; + return findDomainRedirectTarget(redirects, host); } const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); From 46f5b065967d9a5543e7c15e847558bf57afcfef Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 3 Feb 2026 13:43:18 +0100 Subject: [PATCH 15/41] remove unneccessary type check --- demo/site/src/middleware/redirectToMainHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index ff93602a5f5..1d3884299f3 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -102,7 +102,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (domainRedirectTarget) { let destination: string | undefined; - if (typeof domainRedirectTarget === "object" && domainRedirectTarget.block !== undefined) { + if (domainRedirectTarget.block !== undefined) { destination = getRedirectTargetUrl(domainRedirectTarget.block, host); } if (destination) { From 64d98135b1934632b05b6ec0c50643905159a948 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 3 Feb 2026 13:46:49 +0100 Subject: [PATCH 16/41] redirect to site's main domain --- demo/site/src/middleware/redirectToMainHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 1d3884299f3..bd7636f75e1 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -103,7 +103,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (domainRedirectTarget) { let destination: string | undefined; if (domainRedirectTarget.block !== undefined) { - destination = getRedirectTargetUrl(domainRedirectTarget.block, host); + destination = getRedirectTargetUrl(domainRedirectTarget.block, `https://${redirectSiteConfig.domains.main}`); } if (destination) { return NextResponse.redirect(destination, { status: 301 }); From 6b811ea864228cf664874c25f215a4f8780d44a8 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 3 Feb 2026 14:06:49 +0100 Subject: [PATCH 17/41] handle news in getRedirectTargetUrl --- .../[domain]/[language]/[[...path]]/page.tsx | 14 ++------------ demo/site/src/util/getRedirectTargetUrl.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 13 deletions(-) diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx index 8ab24fb3e55..02673fa0760 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx @@ -1,7 +1,7 @@ export const dynamic = "error"; import { gql } from "@comet/site-nextjs"; -import { type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type RedirectsLinkBlockData } from "@src/blocks.generated"; import { documentTypes } from "@src/documents"; import { type VisibilityParam } from "@src/middleware/domainRewrite"; import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; @@ -66,17 +66,7 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain] if (!data.pageTreeNodeByPath?.documentType) { if (data.redirectBySource?.target) { const target = data.redirectBySource?.target as RedirectsLinkBlockData; - let destination: string | undefined; - if (target.block !== undefined) { - if (target.block.type === "news") { - const newsLink = target.block.props as NewsLinkBlockData; - if (newsLink.news) { - destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; - } - } else { - destination = getRedirectTargetUrl(target.block, domain); - } - } + const destination = target.block ? getRedirectTargetUrl(target.block, domain) : undefined; if (destination) { redirect(destination); } diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index 354997014a3..fe5fc251c77 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -1,4 +1,4 @@ -import { type ExternalLinkBlockData, type InternalLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { createSitePath } from "./createSitePath"; @@ -22,6 +22,14 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos } case "external": return (block.props as ExternalLinkBlockData).targetUrl; + case "news": { + const newsLink = block.props as NewsLinkBlockData; + if (newsLink.news) { + const destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; + return destination.startsWith("/") ? `http://${host}${destination}` : destination; + } + break; + } } return undefined; } From fe783d358f7f4c839c340ee82f11078e5027312c Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 9 Feb 2026 15:03:33 +0100 Subject: [PATCH 18/41] add fallback if no redirectSiteConfig is available --- .../site/src/middleware/redirectToMainHost.ts | 63 +++++++++++++++---- 1 file changed, 52 insertions(+), 11 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index bd7636f75e1..e85cfa6d7ff 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -9,7 +9,7 @@ import { type NextRequest, NextResponse } from "next/server"; import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; -type Redirect = { source: string; target: RedirectsLinkBlockData; sourceType: string }; +type Redirect = { source: string; target: RedirectsLinkBlockData; sourceType: string; scope: { domain: string } }; const domainRedirectsQuery = gql` query DomainRedirects($scope: RedirectScopeInput!, $offset: Int, $limit: Int) { @@ -19,21 +19,23 @@ const domainRedirectsQuery = gql` source target sourceType + scope { + domain + } } totalCount } } `; -async function fetchDomainRedirects(domain: string): Promise { - const key = `domainRedirects-${domain}`; - return memoryCache.wrap(key, async () => { - const graphQLFetch = createGraphQLFetch(); - const limit = 50; +async function fetchPaginatedDomainRedirects(domains: string[]): Promise { + const graphQLFetch = createGraphQLFetch(); + const limit = 50; + + async function fetchForDomain(domain: string): Promise { + let allNodes: Redirect[] = []; let totalCount = 0; let currentCount = 0; - let allNodes: Redirect[] = []; - do { const data = await graphQLFetch< { @@ -54,15 +56,33 @@ async function fetchDomainRedirects(domain: string): Promise { allNodes = allNodes.concat(nodes); } while (currentCount < totalCount); return allNodes; - }); + } + + const results = await Promise.all(domains.map(fetchForDomain)); + return results.flat(); +} + +async function fetchDomainRedirects(domain: string): Promise { + const key = `domainRedirects-${domain}`; + return memoryCache.wrap(key, () => fetchPaginatedDomainRedirects([domain])); +} + +async function fetchDomainRedirectsForAllScopes(): Promise { + const key = `domainRedirects-all-scopes`; + const allDomains = getSiteConfigs().map((config) => config.scope.domain); + return memoryCache.wrap(key, () => fetchPaginatedDomainRedirects(allDomains)); } function normalizeHost(value: string): string { return value.replace(/^https?:\/\//, ""); } +function findDomainRedirect(redirects: Redirect[], host: string): Redirect | undefined { + return redirects.find((redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host)); +} + function findDomainRedirectTarget(redirects: Redirect[], host: string): RedirectsLinkBlockData | undefined { - const matching = redirects.find((redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host)); + const matching = findDomainRedirect(redirects, host); return matching ? matching.target : undefined; } @@ -71,6 +91,12 @@ async function getDomainRedirectTarget(domain: string, host: string): Promise { + const redirects = await fetchDomainRedirectsForAllScopes(); + const matching = findDomainRedirect(redirects, host); + return matching ? { target: matching.target, scopeDomain: matching.scope.domain } : undefined; +} + const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { @@ -103,7 +129,22 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (domainRedirectTarget) { let destination: string | undefined; if (domainRedirectTarget.block !== undefined) { - destination = getRedirectTargetUrl(domainRedirectTarget.block, `https://${redirectSiteConfig.domains.main}`); + destination = getRedirectTargetUrl(domainRedirectTarget.block, redirectSiteConfig.domains.main); + } + if (destination) { + return NextResponse.redirect(destination, { status: 301 }); + } + } + } else { + const domainRedirectTarget = await getDomainRedirectTargetForAllScopes(host); + + if (domainRedirectTarget) { + const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === domainRedirectTarget.scopeDomain); + const redirectHost = scopedSiteConfig?.domains.main ?? host; + + let destination: string | undefined; + if (domainRedirectTarget.target.block !== undefined) { + destination = getRedirectTargetUrl(domainRedirectTarget.target.block, redirectHost); } if (destination) { return NextResponse.redirect(destination, { status: 301 }); From e85fa756e5fe37ca9eac37f2701579a546e4b0c9 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 10 Feb 2026 09:32:42 +0100 Subject: [PATCH 19/41] correct message in alert --- packages/admin/cms-admin/src/redirects/RedirectForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index 00a443e7b05..d301f7e644a 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -222,7 +222,7 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element From f1af8c3177829eca36a075e0cc40597aa9a9eb88 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 10 Feb 2026 10:45:50 +0100 Subject: [PATCH 20/41] remove protocol from domain and adjust validation of field --- packages/admin/cms-admin/src/redirects/RedirectForm.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx index d301f7e644a..afe857e49ae 100644 --- a/packages/admin/cms-admin/src/redirects/RedirectForm.tsx +++ b/packages/admin/cms-admin/src/redirects/RedirectForm.tsx @@ -16,13 +16,13 @@ import { useStackSwitchApi, } from "@comet/admin"; import { Box, MenuItem } from "@mui/material"; +import { isFQDN } from "class-validator"; import isEqual from "lodash.isequal"; import { useMemo } from "react"; import { FormattedMessage, useIntl } from "react-intl"; import { createFinalFormBlock } from "../blocks/form/createFinalFormBlock"; import { type BlockInterface, type BlockState } from "../blocks/types"; -import { isValidUrl } from "../blocks/validators/isValidUrl"; import { ContentScopeIndicator } from "../contentScope/ContentScopeIndicator"; import { type GQLRedirectSourceTypeValues } from "../graphql.generated"; import { type GQLRedirectSourceAvailableQuery, type GQLRedirectSourceAvailableQueryVariables } from "./RedirectForm.generated"; @@ -157,10 +157,10 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element }; const validateDomain = (value: string) => { - if (!isValidUrl(value)) { + if (!isFQDN(value)) { return intl.formatMessage({ id: "comet.pages.redirects.validate.domain.error", - defaultMessage: "Needs to start with http:// or https://", + defaultMessage: "Needs to be a valid domain (e.g. example.com)", }); } }; @@ -237,7 +237,7 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element // eslint-disable-next-line @typescript-eslint/no-explicit-any validate={validateSource as any} fullWidth - placeholder={values.sourceType === "domain" ? "https://example.com" : "/example-path"} + placeholder={values.sourceType === "domain" ? "example.com" : "/example-path"} disableContentTranslation /> Date: Tue, 10 Feb 2026 10:46:36 +0100 Subject: [PATCH 21/41] hardcord https --- demo/site/src/util/getRedirectTargetUrl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index fe5fc251c77..47324964ae8 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -14,7 +14,7 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos scope: internalLink.targetPage.scope as Pick, }); if (destination && destination.startsWith("/")) { - destination = `http://${host}${destination}`; + destination = `https://${host}${destination}`; } return destination; } @@ -26,7 +26,7 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos const newsLink = block.props as NewsLinkBlockData; if (newsLink.news) { const destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; - return destination.startsWith("/") ? `http://${host}${destination}` : destination; + return destination.startsWith("/") ? `https://${host}${destination}` : destination; } break; } From 91128fb85d496fdfda79d4b0493a56852ea9e353 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 10 Feb 2026 10:53:29 +0100 Subject: [PATCH 22/41] re-add redirect to main host if no domainRedirectTarget is found --- demo/site/src/middleware/redirectToMainHost.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index e85cfa6d7ff..fe8f40df5a7 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -134,6 +134,11 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (destination) { return NextResponse.redirect(destination, { status: 301 }); } + } else { + // Redirect to Main Host + return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { + status: 301, + }); } } else { const domainRedirectTarget = await getDomainRedirectTargetForAllScopes(host); From 329d61ed75437044b3d883e43bfbdf5e98767d94 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 10 Feb 2026 14:36:34 +0100 Subject: [PATCH 23/41] also redirect to main host, if there is no destination --- demo/site/src/middleware/redirectToMainHost.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index fe8f40df5a7..dca16d2c50d 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -134,7 +134,6 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (destination) { return NextResponse.redirect(destination, { status: 301 }); } - } else { // Redirect to Main Host return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { status: 301, From 9a4541c26ed771a44ce04cc0f0f04ae01e19584b Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 10 Feb 2026 14:52:12 +0100 Subject: [PATCH 24/41] rework migration guide --- .../7-migration-guide/migration-from-v8-to-v9.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index fa43e7ed4d4..65676572104 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -219,11 +219,15 @@ If you're using Knip, you may need to add `proxy.ts` as entry point: ### Domain Redirects -Domain redirects can now be set in the admin. To enable domain-based redirects, you need to: +Domain redirects can now be set in the admin. It is necessary to: -1. **Add the relevant domains** to the `additional` array in your site config, so that the middleware can recognize and handle them. +1. **Update your middleware (required for all projects):** to handle the new `domain` source type for redirects. Check for domain-based redirects and perform the appropriate redirect by updating your previous `redirectToMainHost` middleware. -2. **Adapt your middleware** to handle the new `domain` source type for redirects. Check for domain-based redirects and perform the appropriate redirect by updating your previous `redirectToMainHost` middleware. +2. **Add relevant domains (if feature is used):** Include all necessary domains in the `additional` array of your site config. This allows the middleware to recognize and process them correctly. + +#### Example: Middleware Usage + +Update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts #### Example: Site Config @@ -240,10 +244,6 @@ export default ((env) => { }) satisfies GetSiteConfig; ``` -#### Example: Middleware Usage - -Update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts - #### Admin UI In the admin, you can now select `domain` as the source type when creating a redirect. Enter the full domain (e.g., `https://mydomain.com`) as the source, and specify the target as usual. From b71790684306d46de1d96730981ab33d8576cac5 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Mon, 16 Feb 2026 12:25:29 +0100 Subject: [PATCH 25/41] rework migration guide --- .../migration-from-v8-to-v9.md | 31 +------------------ 1 file changed, 1 insertion(+), 30 deletions(-) diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index 65676572104..1f4d2b47eb6 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -219,36 +219,7 @@ If you're using Knip, you may need to add `proxy.ts` as entry point: ### Domain Redirects -Domain redirects can now be set in the admin. It is necessary to: - -1. **Update your middleware (required for all projects):** to handle the new `domain` source type for redirects. Check for domain-based redirects and perform the appropriate redirect by updating your previous `redirectToMainHost` middleware. - -2. **Add relevant domains (if feature is used):** Include all necessary domains in the `additional` array of your site config. This allows the middleware to recognize and process them correctly. - -#### Example: Middleware Usage - -Update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts - -#### Example: Site Config - -```ts title="site-configs/main.ts" -export default ((env) => { - return { - //... - domains: { - main: envToDomainMap[env], - additional: ["test.localhost:3000"], // Add your additional domains here - }, - //... - }; -}) satisfies GetSiteConfig; -``` - -#### Admin UI - -In the admin, you can now select `domain` as the source type when creating a redirect. Enter the full domain (e.g., `https://mydomain.com`) as the source, and specify the target as usual. - -**Note:** Domain redirects only work if the DNS entry for the domain points to your web server. +Domain redirects can now be set in the admin. It is necessary to update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts ### Add `cache: "force-cache"` to GraphQL fetch From 809d049c848659c51bd1c8994123fe8c49f26e78 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 10:53:34 +0100 Subject: [PATCH 26/41] rework middleware --- .../[domain]/[language]/[[...path]]/page.tsx | 5 +- .../site/src/middleware/redirectToMainHost.ts | 109 +++++++----------- demo/site/src/util/getRedirectTargetUrl.ts | 16 +-- 3 files changed, 52 insertions(+), 78 deletions(-) diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx index 02673fa0760..205a694cccc 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx @@ -1,7 +1,6 @@ export const dynamic = "error"; import { gql } from "@comet/site-nextjs"; -import { type RedirectsLinkBlockData } from "@src/blocks.generated"; import { documentTypes } from "@src/documents"; import { type VisibilityParam } from "@src/middleware/domainRewrite"; import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; @@ -65,8 +64,8 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain] if (!data.pageTreeNodeByPath?.documentType) { if (data.redirectBySource?.target) { - const target = data.redirectBySource?.target as RedirectsLinkBlockData; - const destination = target.block ? getRedirectTargetUrl(target.block, domain) : undefined; + const target = data.redirectBySource?.target; + const destination = getRedirectTargetUrl(target.block); if (destination) { redirect(destination); } diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index dca16d2c50d..65aa991faa0 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,15 +1,14 @@ import { gql } from "@comet/site-nextjs"; -import { type RedirectsLinkBlockData } from "@src/blocks.generated"; +import { type GQLRedirectScopeInput } from "@src/graphql.generated"; import type { PublicSiteConfig } from "@src/site-configs"; import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; -import { createGraphQLFetch } from "@src/util/graphQLClient"; +import { createGraphQLFetchMiddleware } from "@src/util/graphQLClientMiddleware"; import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; import { type NextRequest, NextResponse } from "next/server"; import { memoryCache } from "./cache"; import { type CustomMiddleware } from "./chain"; - -type Redirect = { source: string; target: RedirectsLinkBlockData; sourceType: string; scope: { domain: string } }; +import { type GQLDomainRedirectsQuery, type GQLDomainRedirectsQueryVariables } from "./redirectToMainHost.generated"; const domainRedirectsQuery = gql` query DomainRedirects($scope: RedirectScopeInput!, $offset: Int, $limit: Int) { @@ -28,25 +27,21 @@ const domainRedirectsQuery = gql` } `; -async function fetchPaginatedDomainRedirects(domains: string[]): Promise { - const graphQLFetch = createGraphQLFetch(); - const limit = 50; +const graphQLFetch = createGraphQLFetchMiddleware(); + +type Redirect = GQLDomainRedirectsQuery["paginatedRedirects"]["nodes"][number]; + +async function fetchDomainRedirects(scope: GQLRedirectScopeInput) { + const key = `domainRedirects-${scope.domain}`; + return memoryCache.wrap(key, async () => { + const limit = 50; - async function fetchForDomain(domain: string): Promise { let allNodes: Redirect[] = []; let totalCount = 0; let currentCount = 0; do { - const data = await graphQLFetch< - { - paginatedRedirects: { - nodes: Redirect[]; - totalCount: number; - }; - }, - { scope: { domain: string }; offset: number; limit: number } - >(domainRedirectsQuery, { - scope: { domain }, + const data = await graphQLFetch(domainRedirectsQuery, { + scope, offset: currentCount, limit, }); @@ -56,47 +51,17 @@ async function fetchPaginatedDomainRedirects(domains: string[]): Promise { - const key = `domainRedirects-${domain}`; - return memoryCache.wrap(key, () => fetchPaginatedDomainRedirects([domain])); -} - -async function fetchDomainRedirectsForAllScopes(): Promise { - const key = `domainRedirects-all-scopes`; - const allDomains = getSiteConfigs().map((config) => config.scope.domain); - return memoryCache.wrap(key, () => fetchPaginatedDomainRedirects(allDomains)); +async function fetchDomainRedirectsForAllScopes() { + return (await Promise.all(getSiteConfigs().map((config) => fetchDomainRedirects(config.scope)))).flat(); } function normalizeHost(value: string): string { return value.replace(/^https?:\/\//, ""); } -function findDomainRedirect(redirects: Redirect[], host: string): Redirect | undefined { - return redirects.find((redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host)); -} - -function findDomainRedirectTarget(redirects: Redirect[], host: string): RedirectsLinkBlockData | undefined { - const matching = findDomainRedirect(redirects, host); - return matching ? matching.target : undefined; -} - -async function getDomainRedirectTarget(domain: string, host: string): Promise { - const redirects = await fetchDomainRedirects(domain); - return findDomainRedirectTarget(redirects, host); -} - -async function getDomainRedirectTargetForAllScopes(host: string): Promise<{ target: RedirectsLinkBlockData; scopeDomain: string } | undefined> { - const redirects = await fetchDomainRedirectsForAllScopes(); - const matching = findDomainRedirect(redirects, host); - return matching ? { target: matching.target, scopeDomain: matching.scope.domain } : undefined; -} - const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { @@ -124,32 +89,40 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { if (redirectSiteConfig) { const { scope } = redirectSiteConfig; - const domainRedirectTarget = await getDomainRedirectTarget(scope.domain, host); + const domainRedirects = await fetchDomainRedirects(scope); + + //TODO: Source type check is unneccessary, if filter for sourceType + const redirect = domainRedirects.find( + (redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host), + ); + + if (redirect) { + const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); - if (domainRedirectTarget) { - let destination: string | undefined; - if (domainRedirectTarget.block !== undefined) { - destination = getRedirectTargetUrl(domainRedirectTarget.block, redirectSiteConfig.domains.main); - } if (destination) { return NextResponse.redirect(destination, { status: 301 }); } - // Redirect to Main Host - return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { - status: 301, - }); } + // Redirect to Main Host + return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { + status: 301, + }); } else { - const domainRedirectTarget = await getDomainRedirectTargetForAllScopes(host); + const domainRedirects = await fetchDomainRedirectsForAllScopes(); - if (domainRedirectTarget) { - const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === domainRedirectTarget.scopeDomain); - const redirectHost = scopedSiteConfig?.domains.main ?? host; + //TODO: Source type check is unneccessary, if filter for sourceType + const redirect = domainRedirects.find( + (redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host), + ); + if (redirect) { + const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); - let destination: string | undefined; - if (domainRedirectTarget.target.block !== undefined) { - destination = getRedirectTargetUrl(domainRedirectTarget.target.block, redirectHost); + if (!scopedSiteConfig) { + throw new Error(`Site config not found for domain: ${redirect.scope.domain}`); } + + const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); + if (destination) { return NextResponse.redirect(destination, { status: 301 }); } diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index 47324964ae8..f4027842aa4 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -3,7 +3,7 @@ import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { createSitePath } from "./createSitePath"; -export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], host: string): string | undefined { +export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], host = ""): string | undefined { if (!block) return undefined; switch (block.type) { case "internal": { @@ -11,11 +11,10 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos if (internalLink.targetPage) { let destination = createSitePath({ path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as Pick, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, }); - if (destination && destination.startsWith("/")) { - destination = `https://${host}${destination}`; - } + destination = `${host}${destination}`; + return destination; } break; @@ -25,8 +24,11 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos case "news": { const newsLink = block.props as NewsLinkBlockData; if (newsLink.news) { - const destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; - return destination.startsWith("/") ? `https://${host}${destination}` : destination; + const destination = createSitePath({ + path: `/news/${newsLink.news.slug}`, + scope: newsLink.news.scope, + }); + return `${host}${destination}`; } break; } From 099282e0bc6f9641f68d2c59702072861602a89c Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 11:04:24 +0100 Subject: [PATCH 27/41] add sourceType filter in api --- demo/api/schema.gql | 7 ++++ packages/api/cms-api/schema.gql | 7 ++++ .../src/redirects/dto/redirects.filter.ts | 11 ++++++ .../src/redirects/redirects.util.test.ts | 35 ++++++++++++++++++- .../cms-api/src/redirects/redirects.util.ts | 22 +++++++++++- 5 files changed, 80 insertions(+), 2 deletions(-) diff --git a/demo/api/schema.gql b/demo/api/schema.gql index 9e6e413914a..ca4b6fb824f 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -1677,6 +1677,7 @@ input RedirectFilter { generationType: StringFilter or: [RedirectFilter!] source: StringFilter + sourceType: RedirectSourceTypeValuesEnumFilter target: StringFilter updatedAt: DateTimeFilter } @@ -1719,6 +1720,12 @@ enum RedirectSourceTypeValues { path } +input RedirectSourceTypeValuesEnumFilter { + equal: RedirectSourceTypeValues + isAnyOf: [RedirectSourceTypeValues!] + notEqual: RedirectSourceTypeValues +} + input RedirectUpdateActivenessInput { active: Boolean! } diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index dce28d65d71..58f255e18aa 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -514,6 +514,7 @@ input RedirectFilter { source: StringFilter target: StringFilter active: BooleanFilter + sourceType: RedirectSourceTypeValuesEnumFilter activatedAt: DateTimeFilter createdAt: DateTimeFilter updatedAt: DateTimeFilter @@ -537,6 +538,12 @@ input BooleanFilter { equal: Boolean } +input RedirectSourceTypeValuesEnumFilter { + isAnyOf: [RedirectSourceTypeValues!] + equal: RedirectSourceTypeValues + notEqual: RedirectSourceTypeValues +} + input DateTimeFilter { equal: DateTime lowerThan: DateTime diff --git a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts index 715d8624e2e..e814dc60872 100644 --- a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts +++ b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts @@ -4,7 +4,12 @@ import { IsOptional, ValidateNested } from "class-validator"; import { BooleanFilter } from "../../common/filter/boolean.filter"; import { DateTimeFilter } from "../../common/filter/date-time.filter"; +import { createEnumFilter } from "../../common/filter/enum.filter.factory"; import { StringFilter } from "../../common/filter/string.filter"; +import { RedirectSourceTypeValues } from "../redirects.enum"; + +@InputType() +class RedirectSourceTypeValuesEnumFilter extends createEnumFilter(RedirectSourceTypeValues) {} @InputType() export class RedirectFilter { @@ -28,6 +33,12 @@ export class RedirectFilter { @Type(() => BooleanFilter) active?: BooleanFilter; + @Field(() => RedirectSourceTypeValuesEnumFilter, { nullable: true }) + @ValidateNested() + @IsOptional() + @Type(() => RedirectSourceTypeValuesEnumFilter) + sourceType?: RedirectSourceTypeValuesEnumFilter; + @Field(() => DateTimeFilter, { nullable: true }) @ValidateNested() @Type(() => DateTimeFilter) diff --git a/packages/api/cms-api/src/redirects/redirects.util.test.ts b/packages/api/cms-api/src/redirects/redirects.util.test.ts index 223ff996f86..514a9cdd39e 100644 --- a/packages/api/cms-api/src/redirects/redirects.util.test.ts +++ b/packages/api/cms-api/src/redirects/redirects.util.test.ts @@ -1,6 +1,6 @@ import { addDays, subDays } from "date-fns"; -import { RedirectGenerationType } from "./redirects.enum"; +import { RedirectGenerationType, RedirectSourceTypeValues } from "./redirects.enum"; import { type FilterableRedirect, isEmptyFilter, redirectMatchesFilter } from "./redirects.util"; describe("redirectMatchesFilter", () => { @@ -8,6 +8,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -21,6 +22,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -56,6 +58,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -67,6 +70,31 @@ describe("redirectMatchesFilter", () => { expect(redirectMatchesFilter(redirect, { active: {} })).toBe(true); }); + it("should match for enum filter", () => { + const redirect: FilterableRedirect = { + generationType: RedirectGenerationType.manual, + source: "/source", + sourceType: RedirectSourceTypeValues.path, + target: "/target", + active: true, + createdAt: new Date(), + updatedAt: new Date(), + }; + + expect(redirectMatchesFilter(redirect, { sourceType: { equal: RedirectSourceTypeValues.path } })).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { equal: RedirectSourceTypeValues.domain } })).toBe(false); + + expect(redirectMatchesFilter(redirect, { sourceType: { notEqual: RedirectSourceTypeValues.domain } })).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { notEqual: RedirectSourceTypeValues.path } })).toBe(false); + + expect( + redirectMatchesFilter(redirect, { + sourceType: { isAnyOf: [RedirectSourceTypeValues.path, RedirectSourceTypeValues.domain] }, + }), + ).toBe(true); + expect(redirectMatchesFilter(redirect, { sourceType: { isAnyOf: [RedirectSourceTypeValues.domain] } })).toBe(false); + }); + it("should match for date time filter", () => { const today = new Date(); const yesterday = subDays(today, 1); @@ -75,6 +103,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: yesterday, @@ -107,6 +136,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -121,6 +151,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -139,6 +170,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "/target", active: true, createdAt: new Date(), @@ -161,6 +193,7 @@ describe("redirectMatchesFilter", () => { const redirect: FilterableRedirect = { generationType: RedirectGenerationType.manual, source: "/source", + sourceType: RedirectSourceTypeValues.path, target: "https://example.com/target", active: true, createdAt: new Date(), diff --git a/packages/api/cms-api/src/redirects/redirects.util.ts b/packages/api/cms-api/src/redirects/redirects.util.ts index 13c1a1924d4..293f0999d3e 100644 --- a/packages/api/cms-api/src/redirects/redirects.util.ts +++ b/packages/api/cms-api/src/redirects/redirects.util.ts @@ -1,10 +1,14 @@ import { type BooleanFilter } from "../common/filter/boolean.filter"; import { type DateTimeFilter } from "../common/filter/date-time.filter"; +import { type EnumFilterInterface } from "../common/filter/enum.filter.factory"; import { type StringFilter } from "../common/filter/string.filter"; import { type RedirectFilter } from "./dto/redirects.filter"; import { type RedirectInterface } from "./entities/redirect-entity.factory"; -export type FilterableRedirect = Pick & { +export type FilterableRedirect = Pick< + RedirectInterface, + "generationType" | "source" | "sourceType" | "active" | "createdAt" | "updatedAt" | "activatedAt" +> & { target?: string; }; @@ -37,6 +41,11 @@ export function redirectMatchesFilter(redirect: FilterableRedirect, filter: Redi if (filter.active) { matches = booleanMatchesFilter(redirect.active, filter.active); } + + if (filter.sourceType) { + matches = enumMatchesFilter(redirect.sourceType, filter.sourceType); + } + if (filter.createdAt) { matches = dateTimeMatchesFilter(redirect.createdAt, filter.createdAt); } @@ -123,6 +132,17 @@ function dateTimeMatchesFilter(date: Date | undefined, filter: DateTimeFilter): return false; } +function enumMatchesFilter(value: TEnum | undefined, filter: EnumFilterInterface): boolean { + if (filter.equal !== undefined && value === filter.equal) { + return true; + } else if (filter.notEqual !== undefined && value !== filter.notEqual) { + return true; + } else if (filter.isAnyOf && filter.isAnyOf.includes(value as TEnum)) { + return true; + } + return false; +} + export function isEmptyFilter(filter: RedirectFilter): boolean { const filters = Object.keys(filter); From c8568dac9e5d4d3bba67672615144a2b73505665 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 11:16:25 +0100 Subject: [PATCH 28/41] apply filter --- demo/site/src/middleware/redirectToMainHost.ts | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 65aa991faa0..dda8f410077 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -11,8 +11,8 @@ import { type CustomMiddleware } from "./chain"; import { type GQLDomainRedirectsQuery, type GQLDomainRedirectsQueryVariables } from "./redirectToMainHost.generated"; const domainRedirectsQuery = gql` - query DomainRedirects($scope: RedirectScopeInput!, $offset: Int, $limit: Int) { - paginatedRedirects(scope: $scope, offset: $offset, limit: $limit) { + query DomainRedirects($scope: RedirectScopeInput!, $filter: RedirectFilter, $offset: Int, $limit: Int) { + paginatedRedirects(scope: $scope, filter: $filter, offset: $offset, limit: $limit) { nodes { id source @@ -42,6 +42,7 @@ async function fetchDomainRedirects(scope: GQLRedirectScopeInput) { do { const data = await graphQLFetch(domainRedirectsQuery, { scope, + filter: { sourceType: { equal: "domain" } }, offset: currentCount, limit, }); @@ -91,10 +92,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const domainRedirects = await fetchDomainRedirects(scope); - //TODO: Source type check is unneccessary, if filter for sourceType - const redirect = domainRedirects.find( - (redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host), - ); + const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); if (redirect) { const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); @@ -110,10 +108,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { } else { const domainRedirects = await fetchDomainRedirectsForAllScopes(); - //TODO: Source type check is unneccessary, if filter for sourceType - const redirect = domainRedirects.find( - (redirect) => redirect.sourceType === "domain" && normalizeHost(redirect.source) === normalizeHost(host), - ); + const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); if (redirect) { const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); From 5805ef4a7bb7371857a1b09e0ad58189b3a6ecf6 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 15:04:56 +0100 Subject: [PATCH 29/41] add curly braces to if statement to avoid conflicts with new eslint rule --- demo/site/src/middleware/redirectToMainHost.ts | 12 +++++++++--- demo/site/src/util/getRedirectTargetUrl.ts | 4 +++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index dda8f410077..0b4790f9949 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -66,13 +66,19 @@ function normalizeHost(value: string): string { const normalizeDomain = (host: string) => (host.startsWith("www.") ? host.substring(4) : host); const matchesHostWithAdditionalDomain = (siteConfig: PublicSiteConfig, host: string) => { - if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) return true; // non-www redirect - if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) return true; + if (normalizeDomain(siteConfig.domains.main) === normalizeDomain(host)) { + return true; // non-www redirect + } + if (siteConfig.domains.additional?.map(normalizeDomain).includes(normalizeDomain(host))) { + return true; + } return false; }; const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { - if (!siteConfig.domains.pattern) return false; + if (!siteConfig.domains.pattern) { + return false; + } return new RegExp(siteConfig.domains.pattern).test(host); }; diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index f4027842aa4..d24e361a555 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -4,7 +4,9 @@ import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { createSitePath } from "./createSitePath"; export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], host = ""): string | undefined { - if (!block) return undefined; + if (!block) { + return undefined; + } switch (block.type) { case "internal": { const internalLink = block.props as InternalLinkBlockData; From 5a1449d799f75499c0c9205c2f126c6f2b2af745 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 15:11:22 +0100 Subject: [PATCH 30/41] remove destination variable and directly return --- demo/site/src/util/getRedirectTargetUrl.ts | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index d24e361a555..f8b9be6ebbb 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -11,13 +11,10 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos case "internal": { const internalLink = block.props as InternalLinkBlockData; if (internalLink.targetPage) { - let destination = createSitePath({ + return `${host}${createSitePath({ path: internalLink.targetPage.path, scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, - }); - destination = `${host}${destination}`; - - return destination; + })}`; } break; } @@ -26,11 +23,10 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos case "news": { const newsLink = block.props as NewsLinkBlockData; if (newsLink.news) { - const destination = createSitePath({ + return createSitePath({ path: `/news/${newsLink.news.slug}`, scope: newsLink.news.scope, }); - return `${host}${destination}`; } break; } From 6ceba1413a92cd0bd87b4a2dfbdeebd58d2979ed Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 15:18:10 +0100 Subject: [PATCH 31/41] fix types for filter in redirects util --- packages/api/cms-api/src/redirects/redirects.util.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/cms-api/src/redirects/redirects.util.ts b/packages/api/cms-api/src/redirects/redirects.util.ts index 293f0999d3e..dacde114d37 100644 --- a/packages/api/cms-api/src/redirects/redirects.util.ts +++ b/packages/api/cms-api/src/redirects/redirects.util.ts @@ -132,12 +132,12 @@ function dateTimeMatchesFilter(date: Date | undefined, filter: DateTimeFilter): return false; } -function enumMatchesFilter(value: TEnum | undefined, filter: EnumFilterInterface): boolean { +function enumMatchesFilter(value: TEnum, filter: EnumFilterInterface): boolean { if (filter.equal !== undefined && value === filter.equal) { return true; } else if (filter.notEqual !== undefined && value !== filter.notEqual) { return true; - } else if (filter.isAnyOf && filter.isAnyOf.includes(value as TEnum)) { + } else if (filter.isAnyOf && filter.isAnyOf.includes(value)) { return true; } return false; From 1d6cfcbc886d8d7d5eee4c19c78419a755f9dec7 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Wed, 18 Feb 2026 15:25:42 +0100 Subject: [PATCH 32/41] remove values suffix from RedirectSourceTypeValuesEnumFilter --- demo/api/schema.gql | 14 +++++++------- packages/api/cms-api/schema.gql | 4 ++-- .../cms-api/src/redirects/dto/redirects.filter.ts | 8 ++++---- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/demo/api/schema.gql b/demo/api/schema.gql index ca4b6fb824f..c294fe8122d 100644 --- a/demo/api/schema.gql +++ b/demo/api/schema.gql @@ -1677,7 +1677,7 @@ input RedirectFilter { generationType: StringFilter or: [RedirectFilter!] source: StringFilter - sourceType: RedirectSourceTypeValuesEnumFilter + sourceType: RedirectSourceTypeEnumFilter target: StringFilter updatedAt: DateTimeFilter } @@ -1715,17 +1715,17 @@ enum RedirectSortField { updatedAt } -enum RedirectSourceTypeValues { - domain - path -} - -input RedirectSourceTypeValuesEnumFilter { +input RedirectSourceTypeEnumFilter { equal: RedirectSourceTypeValues isAnyOf: [RedirectSourceTypeValues!] notEqual: RedirectSourceTypeValues } +enum RedirectSourceTypeValues { + domain + path +} + input RedirectUpdateActivenessInput { active: Boolean! } diff --git a/packages/api/cms-api/schema.gql b/packages/api/cms-api/schema.gql index 58f255e18aa..ea1849d18b4 100644 --- a/packages/api/cms-api/schema.gql +++ b/packages/api/cms-api/schema.gql @@ -514,7 +514,7 @@ input RedirectFilter { source: StringFilter target: StringFilter active: BooleanFilter - sourceType: RedirectSourceTypeValuesEnumFilter + sourceType: RedirectSourceTypeEnumFilter activatedAt: DateTimeFilter createdAt: DateTimeFilter updatedAt: DateTimeFilter @@ -538,7 +538,7 @@ input BooleanFilter { equal: Boolean } -input RedirectSourceTypeValuesEnumFilter { +input RedirectSourceTypeEnumFilter { isAnyOf: [RedirectSourceTypeValues!] equal: RedirectSourceTypeValues notEqual: RedirectSourceTypeValues diff --git a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts index e814dc60872..63e0bf2bfa3 100644 --- a/packages/api/cms-api/src/redirects/dto/redirects.filter.ts +++ b/packages/api/cms-api/src/redirects/dto/redirects.filter.ts @@ -9,7 +9,7 @@ import { StringFilter } from "../../common/filter/string.filter"; import { RedirectSourceTypeValues } from "../redirects.enum"; @InputType() -class RedirectSourceTypeValuesEnumFilter extends createEnumFilter(RedirectSourceTypeValues) {} +class RedirectSourceTypeEnumFilter extends createEnumFilter(RedirectSourceTypeValues) {} @InputType() export class RedirectFilter { @@ -33,11 +33,11 @@ export class RedirectFilter { @Type(() => BooleanFilter) active?: BooleanFilter; - @Field(() => RedirectSourceTypeValuesEnumFilter, { nullable: true }) + @Field(() => RedirectSourceTypeEnumFilter, { nullable: true }) @ValidateNested() @IsOptional() - @Type(() => RedirectSourceTypeValuesEnumFilter) - sourceType?: RedirectSourceTypeValuesEnumFilter; + @Type(() => RedirectSourceTypeEnumFilter) + sourceType?: RedirectSourceTypeEnumFilter; @Field(() => DateTimeFilter, { nullable: true }) @ValidateNested() From dfcb7b2573815452fe705d9f34850ca6f4076c94 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 24 Feb 2026 09:00:07 +0100 Subject: [PATCH 33/41] rename host to targetBaseUrl --- demo/site/src/util/getRedirectTargetUrl.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts index f8b9be6ebbb..7f8cdaef438 100644 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ b/demo/site/src/util/getRedirectTargetUrl.ts @@ -3,7 +3,7 @@ import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { createSitePath } from "./createSitePath"; -export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], host = ""): string | undefined { +export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], targetBaseUrl = ""): string | undefined { if (!block) { return undefined; } @@ -11,7 +11,7 @@ export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], hos case "internal": { const internalLink = block.props as InternalLinkBlockData; if (internalLink.targetPage) { - return `${host}${createSitePath({ + return `${targetBaseUrl}${createSitePath({ path: internalLink.targetPage.path, scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, })}`; From c5e7c4d4508e7d3df588deae3209f55e3d842859 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 24 Feb 2026 09:02:16 +0100 Subject: [PATCH 34/41] remove else --- .../site/src/middleware/redirectToMainHost.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 0b4790f9949..1dcc918f23a 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -111,22 +111,22 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { status: 301, }); - } else { - const domainRedirects = await fetchDomainRedirectsForAllScopes(); + } - const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); - if (redirect) { - const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); + const domainRedirects = await fetchDomainRedirectsForAllScopes(); - if (!scopedSiteConfig) { - throw new Error(`Site config not found for domain: ${redirect.scope.domain}`); - } + const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); + if (redirect) { + const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); - const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); + if (!scopedSiteConfig) { + throw new Error(`Site config not found for domain: ${redirect.scope.domain}`); + } - if (destination) { - return NextResponse.redirect(destination, { status: 301 }); - } + const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); + + if (destination) { + return NextResponse.redirect(destination, { status: 301 }); } } From 9e89879e61bfb534e697dc9fef47d605d6c58fa5 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 24 Feb 2026 09:03:03 +0100 Subject: [PATCH 35/41] improve error message --- demo/site/src/middleware/redirectToMainHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 1dcc918f23a..f6366dc907d 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -120,7 +120,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain); if (!scopedSiteConfig) { - throw new Error(`Site config not found for domain: ${redirect.scope.domain}`); + throw new Error(`Got redirect to domain ${redirect.scope.domain}, but couldn't find corresponding site-config.`); } const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); From 0f7b6871cd30fa7028021d1389d7926fe7e7bcec Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Tue, 24 Feb 2026 14:34:59 +0100 Subject: [PATCH 36/41] only pass domain to scope in fetchDomainRedirects --- demo/site/src/middleware/redirectToMainHost.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index f6366dc907d..01082f80b58 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -41,7 +41,7 @@ async function fetchDomainRedirects(scope: GQLRedirectScopeInput) { let currentCount = 0; do { const data = await graphQLFetch(domainRedirectsQuery, { - scope, + scope: { domain: scope.domain }, filter: { sourceType: { equal: "domain" } }, offset: currentCount, limit, From 1d8a17033e22bdbcbc1b9c7a5f61dd5013241894 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 26 Feb 2026 15:13:04 +0100 Subject: [PATCH 37/41] check for redirect loops --- demo/site/src/middleware/redirectToMainHost.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 01082f80b58..5ed654d057a 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -104,13 +104,21 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); if (destination) { + if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { + throw new Error(`Redirect loop detected: ${host} -> ${destination}`); + } return NextResponse.redirect(destination, { status: 301 }); } } // Redirect to Main Host - return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { - status: 301, - }); + const mainHost = normalizeHost(redirectSiteConfig.domains.main); + if (mainHost === normalizeHost(host)) { + throw new Error(`Redirect loop detected: main host ${mainHost} equals current host`); + } else { + return NextResponse.redirect(`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`, { + status: 301, + }); + } } const domainRedirects = await fetchDomainRedirectsForAllScopes(); @@ -126,6 +134,9 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); if (destination) { + if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { + throw new Error(`Redirect loop detected: ${host} -> ${destination}`); + } return NextResponse.redirect(destination, { status: 301 }); } } From e22b315c045dad605460ecde422c5657f4e479e4 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 26 Feb 2026 15:33:13 +0100 Subject: [PATCH 38/41] do not use getRedirectTargetUrl function for page.tsx --- .../[domain]/[language]/[[...path]]/page.tsx | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx index 205a694cccc..04ca6cbb867 100644 --- a/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx +++ b/demo/site/src/app/[visibility]/[domain]/[language]/[[...path]]/page.tsx @@ -1,9 +1,11 @@ export const dynamic = "error"; import { gql } from "@comet/site-nextjs"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; import { documentTypes } from "@src/documents"; +import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; import { type VisibilityParam } from "@src/middleware/domainRewrite"; -import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; +import { createSitePath } from "@src/util/createSitePath"; import { createGraphQLFetch } from "@src/util/graphQLClient"; import { setVisibilityParam } from "@src/util/ServerContext"; import { getSiteConfigForDomain } from "@src/util/siteConfig"; @@ -64,10 +66,34 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain] if (!data.pageTreeNodeByPath?.documentType) { if (data.redirectBySource?.target) { - const target = data.redirectBySource?.target; - const destination = getRedirectTargetUrl(target.block); - if (destination) { - redirect(destination); + const target = data.redirectBySource?.target as RedirectsLinkBlockData; + let destination: string | undefined; + if (target.block !== undefined) { + switch (target.block.type) { + case "internal": { + const internalLink = target.block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, + }); + } + break; + } + case "external": + destination = (target.block.props as ExternalLinkBlockData).targetUrl; + break; + case "news": { + const newsLink = target.block.props as NewsLinkBlockData; + if (newsLink.news) { + destination = `/${newsLink.news.scope.language}/news/${newsLink.news.slug}`; + } + break; + } + } + if (destination) { + redirect(destination); + } } } notFound(); From 64d6c0ad4a2363edd91b7e972c24e42d7483341f Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 5 Mar 2026 13:51:32 +0100 Subject: [PATCH 39/41] update link to redirectToMainHost in migration guide --- docs/docs/7-migration-guide/migration-from-v8-to-v9.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md index 4fdd4a4986f..df21d0069e9 100644 --- a/docs/docs/7-migration-guide/migration-from-v8-to-v9.md +++ b/docs/docs/7-migration-guide/migration-from-v8-to-v9.md @@ -492,7 +492,7 @@ If you're using Knip, you may need to add `proxy.ts` as entry point: ### Domain Redirects -Domain redirects can now be set in the admin. It is necessary to update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/51f4ab31d98772794e3ac733f34e048a35960843/demo/site/src/middleware/redirectToMainHost.ts +Domain redirects can now be set in the admin. It is necessary to update your middleware — most likely the `redirectToMainHost` middleware — to handle domain redirects. See example in the demo here: https://github.com/vivid-planet/comet/blob/main/demo/site/src/middleware/redirectToMainHost.ts ### Add `cache: "force-cache"` to GraphQL fetch From 1fab518ea524e3a706c3247a4d50cb914e07c4fd Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 5 Mar 2026 14:23:59 +0100 Subject: [PATCH 40/41] remove getRedirectTargetUrl function and duplicate switch statement instead --- .../site/src/middleware/redirectToMainHost.ts | 65 +++++++++++++++++-- demo/site/src/util/getRedirectTargetUrl.ts | 35 ---------- 2 files changed, 61 insertions(+), 39 deletions(-) delete mode 100644 demo/site/src/util/getRedirectTargetUrl.ts diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index 5ed654d057a..abab74430a4 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,7 +1,8 @@ import { gql } from "@comet/site-nextjs"; -import { type GQLRedirectScopeInput } from "@src/graphql.generated"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData } from "@src/blocks.generated"; +import { type GQLPageTreeNodeScope, type GQLRedirectScopeInput } from "@src/graphql.generated"; import type { PublicSiteConfig } from "@src/site-configs"; -import { getRedirectTargetUrl } from "@src/util/getRedirectTargetUrl"; +import { createSitePath } from "@src/util/createSitePath"; import { createGraphQLFetchMiddleware } from "@src/util/graphQLClientMiddleware"; import { getHostByHeaders, getSiteConfigForHost, getSiteConfigs } from "@src/util/siteConfig"; import { type NextRequest, NextResponse } from "next/server"; @@ -101,7 +102,35 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); if (redirect) { - const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); + const block = redirect.target.block; + let destination: string | undefined; + if (block) { + switch (block.type) { + case "internal": { + const internalLink = block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = `https://${redirectSiteConfig.domains.main}${createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, + })}`; + } + break; + } + case "external": + destination = (block.props as ExternalLinkBlockData).targetUrl; + break; + case "news": { + const newsLink = block.props as NewsLinkBlockData; + if (newsLink.news) { + destination = createSitePath({ + path: `/news/${newsLink.news.slug}`, + scope: newsLink.news.scope, + }); + } + break; + } + } + } if (destination) { if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { @@ -131,7 +160,35 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { throw new Error(`Got redirect to domain ${redirect.scope.domain}, but couldn't find corresponding site-config.`); } - const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); + const block = redirect.target.block; + let destination: string | undefined; + if (block) { + switch (block.type) { + case "internal": { + const internalLink = block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + destination = `https://${scopedSiteConfig.domains.main}${createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, + })}`; + } + break; + } + case "external": + destination = (block.props as ExternalLinkBlockData).targetUrl; + break; + case "news": { + const newsLink = block.props as NewsLinkBlockData; + if (newsLink.news) { + destination = createSitePath({ + path: `/news/${newsLink.news.slug}`, + scope: newsLink.news.scope, + }); + } + break; + } + } + } if (destination) { if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { diff --git a/demo/site/src/util/getRedirectTargetUrl.ts b/demo/site/src/util/getRedirectTargetUrl.ts deleted file mode 100644 index 7f8cdaef438..00000000000 --- a/demo/site/src/util/getRedirectTargetUrl.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; -import { type GQLPageTreeNodeScope } from "@src/graphql.generated"; - -import { createSitePath } from "./createSitePath"; - -export function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], targetBaseUrl = ""): string | undefined { - if (!block) { - return undefined; - } - switch (block.type) { - case "internal": { - const internalLink = block.props as InternalLinkBlockData; - if (internalLink.targetPage) { - return `${targetBaseUrl}${createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, - })}`; - } - break; - } - case "external": - return (block.props as ExternalLinkBlockData).targetUrl; - case "news": { - const newsLink = block.props as NewsLinkBlockData; - if (newsLink.news) { - return createSitePath({ - path: `/news/${newsLink.news.slug}`, - scope: newsLink.news.scope, - }); - } - break; - } - } - return undefined; -} From 97587c67a3c2930a139b5b2d356d7095ae389718 Mon Sep 17 00:00:00 2001 From: Julia Wegmayr Date: Thu, 5 Mar 2026 15:20:13 +0100 Subject: [PATCH 41/41] add function getRedirectTargetUrl to redirectToMainHost file with required targetUrl argument --- .../site/src/middleware/redirectToMainHost.ts | 94 +++++++------------ 1 file changed, 34 insertions(+), 60 deletions(-) diff --git a/demo/site/src/middleware/redirectToMainHost.ts b/demo/site/src/middleware/redirectToMainHost.ts index abab74430a4..89c6b00fd64 100644 --- a/demo/site/src/middleware/redirectToMainHost.ts +++ b/demo/site/src/middleware/redirectToMainHost.ts @@ -1,5 +1,5 @@ import { gql } from "@comet/site-nextjs"; -import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData } from "@src/blocks.generated"; +import { type ExternalLinkBlockData, type InternalLinkBlockData, type NewsLinkBlockData, type RedirectsLinkBlockData } from "@src/blocks.generated"; import { type GQLPageTreeNodeScope, type GQLRedirectScopeInput } from "@src/graphql.generated"; import type { PublicSiteConfig } from "@src/site-configs"; import { createSitePath } from "@src/util/createSitePath"; @@ -83,6 +83,37 @@ const matchesHostWithPattern = (siteConfig: PublicSiteConfig, host: string) => { return new RegExp(siteConfig.domains.pattern).test(host); }; +function getRedirectTargetUrl(block: RedirectsLinkBlockData["block"], targetBaseUrl: string): string | undefined { + if (!block) { + return undefined; + } + switch (block.type) { + case "internal": { + const internalLink = block.props as InternalLinkBlockData; + if (internalLink.targetPage) { + return `${targetBaseUrl}${createSitePath({ + path: internalLink.targetPage.path, + scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, + })}`; + } + break; + } + case "external": + return (block.props as ExternalLinkBlockData).targetUrl; + case "news": { + const newsLink = block.props as NewsLinkBlockData; + if (newsLink.news) { + return createSitePath({ + path: `/news/${newsLink.news.slug}`, + scope: newsLink.news.scope, + }); + } + break; + } + } + return undefined; +} + export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { return async (request: NextRequest) => { const headers = request.headers; @@ -102,36 +133,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host)); if (redirect) { - const block = redirect.target.block; - let destination: string | undefined; - if (block) { - switch (block.type) { - case "internal": { - const internalLink = block.props as InternalLinkBlockData; - if (internalLink.targetPage) { - destination = `https://${redirectSiteConfig.domains.main}${createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, - })}`; - } - break; - } - case "external": - destination = (block.props as ExternalLinkBlockData).targetUrl; - break; - case "news": { - const newsLink = block.props as NewsLinkBlockData; - if (newsLink.news) { - destination = createSitePath({ - path: `/news/${newsLink.news.slug}`, - scope: newsLink.news.scope, - }); - } - break; - } - } - } - + const destination = getRedirectTargetUrl(redirect.target.block, `https://${redirectSiteConfig.domains.main}`); if (destination) { if (normalizeHost(new URL(destination).host) === normalizeHost(host)) { throw new Error(`Redirect loop detected: ${host} -> ${destination}`); @@ -160,35 +162,7 @@ export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) { throw new Error(`Got redirect to domain ${redirect.scope.domain}, but couldn't find corresponding site-config.`); } - const block = redirect.target.block; - let destination: string | undefined; - if (block) { - switch (block.type) { - case "internal": { - const internalLink = block.props as InternalLinkBlockData; - if (internalLink.targetPage) { - destination = `https://${scopedSiteConfig.domains.main}${createSitePath({ - path: internalLink.targetPage.path, - scope: internalLink.targetPage.scope as GQLPageTreeNodeScope, - })}`; - } - break; - } - case "external": - destination = (block.props as ExternalLinkBlockData).targetUrl; - break; - case "news": { - const newsLink = block.props as NewsLinkBlockData; - if (newsLink.news) { - destination = createSitePath({ - path: `/news/${newsLink.news.slug}`, - scope: newsLink.news.scope, - }); - } - break; - } - } - } + const destination = getRedirectTargetUrl(redirect.target.block, `https://${scopedSiteConfig.domains.main}`); if (destination) { if (normalizeHost(new URL(destination).host) === normalizeHost(host)) {