Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
ac2ed99
add domain as value for the redirects enum
juliawegmayr Nov 26, 2025
4c3de38
add domain as sourceTypeOptions in the redirect form in admin
juliawegmayr Nov 26, 2025
6f5d73d
set placeholder depending on selected source type
juliawegmayr Nov 26, 2025
a829f33
add info box if domain is selected
juliawegmayr Nov 26, 2025
37cd8b1
handle domain redirects in middleware
juliawegmayr Dec 17, 2025
9fbd0d2
add changeset
juliawegmayr Dec 18, 2025
6517ee3
add migration guide
juliawegmayr Dec 18, 2025
49b4f97
fix spelling
juliawegmayr Dec 18, 2025
51f4ab3
improve changeset
juliawegmayr Dec 22, 2025
ec8db1c
add link to migration guide instead of code example
juliawegmayr Dec 22, 2025
54fc79d
re-add host normalization
juliawegmayr Dec 22, 2025
0431db3
add pagination handling
juliawegmayr Dec 22, 2025
8a04470
create and use getRedirectTargetUrl helper
juliawegmayr Dec 22, 2025
16e2a4c
extract functions for single responsability
juliawegmayr Dec 22, 2025
46f5b06
remove unneccessary type check
juliawegmayr Feb 3, 2026
64d9813
redirect to site's main domain
juliawegmayr Feb 3, 2026
6b811ea
handle news in getRedirectTargetUrl
juliawegmayr Feb 3, 2026
fe783d3
add fallback if no redirectSiteConfig is available
juliawegmayr Feb 9, 2026
e85fa75
correct message in alert
juliawegmayr Feb 10, 2026
f1af8c3
remove protocol from domain and adjust validation of field
juliawegmayr Feb 10, 2026
9f4b3a4
hardcord https
juliawegmayr Feb 10, 2026
91128fb
re-add redirect to main host if no domainRedirectTarget is found
juliawegmayr Feb 10, 2026
329d61e
also redirect to main host, if there is no destination
juliawegmayr Feb 10, 2026
9a4541c
rework migration guide
juliawegmayr Feb 10, 2026
b717906
rework migration guide
juliawegmayr Feb 16, 2026
809d049
rework middleware
juliawegmayr Feb 18, 2026
099282e
add sourceType filter in api
juliawegmayr Feb 18, 2026
c8568da
apply filter
juliawegmayr Feb 18, 2026
5805ef4
add curly braces to if statement to avoid conflicts with new eslint rule
juliawegmayr Feb 18, 2026
5a1449d
remove destination variable and directly return
juliawegmayr Feb 18, 2026
6ceba14
fix types for filter in redirects util
juliawegmayr Feb 18, 2026
1d6cfcb
remove values suffix from RedirectSourceTypeValuesEnumFilter
juliawegmayr Feb 18, 2026
dfcb7b2
rename host to targetBaseUrl
juliawegmayr Feb 24, 2026
c5e7c4d
remove else
juliawegmayr Feb 24, 2026
9e89879
improve error message
juliawegmayr Feb 24, 2026
0f7b687
only pass domain to scope in fetchDomainRedirects
juliawegmayr Feb 24, 2026
1d8a170
check for redirect loops
juliawegmayr Feb 26, 2026
e22b315
do not use getRedirectTargetUrl function for page.tsx
juliawegmayr Feb 26, 2026
da004b5
Merge branch 'next' into domain-redirects
juliawegmayr Feb 26, 2026
64d6c0a
update link to redirectToMainHost in migration guide
juliawegmayr Mar 5, 2026
1fab518
remove getRedirectTargetUrl function and duplicate switch statement i…
juliawegmayr Mar 5, 2026
97587c6
add function getRedirectTargetUrl to redirectToMainHost file with req…
juliawegmayr Mar 5, 2026
d22b487
Merge branch 'next' into domain-redirects
juliawegmayr Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/swift-monkeys-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@comet/cms-admin": major
"@comet/cms-api": major
---

Redirects: add `domain` source type

To fully support domain redirects, additional handling is required in the site middleware.
8 changes: 8 additions & 0 deletions demo/api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -1996,6 +1996,7 @@ input RedirectFilter {
generationType: StringFilter
or: [RedirectFilter!]
source: StringFilter
sourceType: RedirectSourceTypeEnumFilter
target: StringFilter
updatedAt: DateTimeFilter
}
Expand Down Expand Up @@ -2033,7 +2034,14 @@ enum RedirectSortField {
updatedAt
}

input RedirectSourceTypeEnumFilter {
equal: RedirectSourceTypeValues
isAnyOf: [RedirectSourceTypeValues!]
notEqual: RedirectSourceTypeValues
}

enum RedirectSourceTypeValues {
domain
path
}

Expand Down
1 change: 1 addition & 0 deletions demo/site-configs/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export default ((env) => {
name: "Comet Site Main",
domains: {
main: envToDomainMap[env],
additional: ["test.localhost:3000"],
},
public: {
scope: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,9 @@ export default async function Page({ params }: PageProps<"/[visibility]/[domain]
break;
}
}
}

if (destination) {
redirect(destination);
if (destination) {
redirect(destination);
}
}
}
notFound();
Expand Down
155 changes: 145 additions & 10 deletions demo/site/src/middleware/redirectToMainHost.ts
Original file line number Diff line number Diff line change
@@ -1,40 +1,175 @@
import { gql } from "@comet/site-nextjs";
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";
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";
import { type GQLDomainRedirectsQuery, type GQLDomainRedirectsQueryVariables } from "./redirectToMainHost.generated";

const domainRedirectsQuery = gql`
query DomainRedirects($scope: RedirectScopeInput!, $filter: RedirectFilter, $offset: Int, $limit: Int) {
paginatedRedirects(scope: $scope, filter: $filter, offset: $offset, limit: $limit) {
nodes {
id
source
target
sourceType
scope {
domain
}
}
totalCount
}
}
`;

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;

let allNodes: Redirect[] = [];
let totalCount = 0;
let currentCount = 0;
do {
const data = await graphQLFetch<GQLDomainRedirectsQuery, GQLDomainRedirectsQueryVariables>(domainRedirectsQuery, {
scope: { domain: scope.domain },
filter: { sourceType: { equal: "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;
});
}

async function fetchDomainRedirectsForAllScopes() {
return (await Promise.all(getSiteConfigs().map((config) => fetchDomainRedirects(config.scope)))).flat();
}

function normalizeHost(value: string): string {
return value.replace(/^https?:\/\//, "");
}

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);
};

/**
* When http host isn't siteConfig.domains.main (instead .pattern or .additional match), redirect to main 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;
const host = getHostByHeaders(headers);
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 domainRedirects = await fetchDomainRedirects(scope);

const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host));

if (redirect) {
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
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();

const redirect = domainRedirects.find((redirect) => normalizeHost(redirect.source) === normalizeHost(host));
if (redirect) {
const scopedSiteConfig = getSiteConfigs().find((config) => config.scope.domain === redirect.scope.domain);

if (!scopedSiteConfig) {
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}`);

if (destination) {
if (normalizeHost(new URL(destination).host) === normalizeHost(host)) {
throw new Error(`Redirect loop detected: ${host} -> ${destination}`);
}
return NextResponse.redirect(destination, { status: 301 });
}
}

return NextResponse.json({ error: `Cannot resolve domain: ${host}` }, { status: 404 });
Expand Down
23 changes: 23 additions & 0 deletions docs/docs/7-migration-guide/migration-from-v8-to-v9.md
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,29 @@ Review the [migration guide](https://nextjs.org/docs/app/guides/upgrading/versio
mv site/src/middleware.ts site/src/proxy.ts
```

:::note

If you're using Knip, you may need to add `proxy.ts` as entry point:

```diff title="site/knip.json"
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"./src/app/**",
"./cache-handler.ts",
"./tracing.ts",
+ "./src/proxy.ts"
],
"project": ["./src/**/*.{ts,tsx}"]
}
```

:::

### 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/main/demo/site/src/middleware/redirectToMainHost.ts

### Add `cache: "force-cache"` to GraphQL fetch

Next.js no longer caches `fetch` requests by default.
Expand Down
28 changes: 23 additions & 5 deletions packages/admin/cms-admin/src/redirects/RedirectForm.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { gql, useApolloClient, useQuery } from "@apollo/client";
import {
Alert,
Field,
FillSpace,
FinalForm,
Expand All @@ -14,14 +15,14 @@ import {
ToolbarTitleItem,
useStackSwitchApi,
} from "@comet/admin";
import { MenuItem } from "@mui/material";
import { Box, MenuItem } from "@mui/material";
import { isFQDN } from "class-validator";
import isEqual from "lodash.isequal";
import { type JSX, 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";
Expand Down Expand Up @@ -97,6 +98,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);
Expand Down Expand Up @@ -149,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)",
});
}
};
Expand Down Expand Up @@ -209,6 +217,16 @@ export const RedirectForm = ({ mode, id, linkBlock, scope }: Props): JSX.Element
</FinalFormSelect>
)}
</Field>
{values.sourceType === "domain" && (
<Box sx={{ marginBottom: 4 }}>
<Alert severity="warning">
<FormattedMessage
id="comet.pages.redirects.redirect.source.type.domain.warning"
defaultMessage="This only works if the domain is configured correctly."
/>
</Alert>
</Box>
)}
<Field
label={intl.formatMessage({ id: "comet.pages.redirects.redirect.source", defaultMessage: "Source" })}
name="source"
Expand All @@ -219,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="/example-path"
placeholder={values.sourceType === "domain" ? "example.com" : "/example-path"}
disableContentTranslation
/>
<Field
Expand Down
8 changes: 8 additions & 0 deletions packages/api/cms-api/schema.gql
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@ type Redirect {

enum RedirectSourceTypeValues {
path
domain
}

enum RedirectGenerationType {
Expand Down Expand Up @@ -514,6 +515,7 @@ input RedirectFilter {
source: StringFilter
target: StringFilter
active: BooleanFilter
sourceType: RedirectSourceTypeEnumFilter
activatedAt: DateTimeFilter
createdAt: DateTimeFilter
updatedAt: DateTimeFilter
Expand All @@ -537,6 +539,12 @@ input BooleanFilter {
equal: Boolean
}

input RedirectSourceTypeEnumFilter {
isAnyOf: [RedirectSourceTypeValues!]
equal: RedirectSourceTypeValues
notEqual: RedirectSourceTypeValues
}

input DateTimeFilter {
equal: DateTime
lowerThan: DateTime
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Migration } from "@mikro-orm/migrations";

export class Migration20251126093305 extends Migration {
override async up(): Promise<void> {
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<void> {}
}
Loading
Loading