Migrating from v8 to v9
This migration guide is designed to be executed by an AI coding agent (e.g., Claude Code). Each section contains structured, step-by-step instructions that an agent can follow to perform the migration automatically.
Sample prompt to get started:
Migrate this project from Comet v8 to v9. Follow the migration guide at https://docs.comet-dxp.com/docs/migration-guide/migration-from-v8-to-v9 step by step. Work through each section sequentially, making the required changes and running any verification commands. Commit after each major section.
Root
Update Comet dependencies
Update the @comet/* dependencies in the root package.json to version 9.0.0:
{
"devDependencies": {
- "@comet/cli": "^8.0.0",
+ "@comet/cli": "9.0.0",
}
}
Then, install the updated dependencies:
npm install
Replace install-agent-skills with install-agent-features
The install-agent-skills command and its agent-skills.json config have been removed and replaced by install-agent-features, a combined installer for agent skills and agent rules.
Comet's skills and rules are now shipped via the @comet/agent-features npm package, which is discovered automatically from node_modules — you no longer need an agent-features.json file to reference the Comet repo.
Delete the old agent-skills.json:
rm agent-skills.json
Add @comet/agent-features to the root package.json and replace the install-agent-skills script:
{
"scripts": {
- "install-agent-skills": "npx @comet/cli install-agent-skills"
+ "install-agent-features": "npx @comet/cli install-agent-features"
},
"devDependencies": {
+ "@comet/agent-features": "9.0.0",
}
}
And in install.sh:
- npx @comet/cli install-agent-skills
+ npm run install-agent-features
If you need to pull in additional skills/rules from external git repos, create an agent-features.json as described in the Installing agent features guide.
Verify lint passes
npm run lint:root
Repeat this step, fixing all lint errors, until the lint passes.
All packages
The following changes apply to API, Admin, and Site. Run the steps in each package that consumes @comet/* packages.
Replace /lib imports from @comet/* packages
@comet/eslint-config v9 forbids importing from @comet/*/lib and @comet/*/lib/**. Only the package root (and explicit subpath exports like @comet/site-nextjs/server) may be imported. Reaching into /lib couples your project to internal file layout and breaks whenever a package is restructured.
- import { Something } from "@comet/cms-api/lib/some/internal/path";
+ import { Something } from "@comet/cms-api";
If the symbol you need isn't exported from the package root, do not copy the code from /lib into your project. The duplicate drifts out of sync with the library, misses bug fixes, and defeats the purpose of using the package.
Instead, open a pull request in vivid-planet/comet that adds the missing export to the package's src/index.ts (plus a changeset). Once merged and released, import it from the package root.
API
Update Comet dependencies
Update all @comet/* dependencies in api/package.json to version 9.0.0:
{
"dependencies": {
- "@comet/cms-api": "^8.0.0",
+ "@comet/cms-api": "9.0.0",
- "@comet/mail-react": "^8.0.0",
+ "@comet/mail-react": "9.0.0",
},
"devDependencies": {
- "@comet/api-generator": "^8.0.0",
+ "@comet/api-generator": "9.0.0",
- "@comet/eslint-config": "^8.0.0",
+ "@comet/eslint-config": "9.0.0",
}
}
Update any other @comet/* packages your project uses (e.g., @comet/brevo-api) to 9.0.0 as well.
Then, install the updated dependencies:
npm install
API Generator: Remove the targetDirectory option
The targetDirectory option of the @CrudGenerator and @CrudSingleGenerator decorators has been removed.
Generated files are now always written to ${__dirname}/../generated/, which was a commonly used default.
@CrudGenerator({
- targetDirectory: `${__dirname}/../generated/`,
requiredPermission: ["products"],
})
export class Product extends BaseEntity {}
@CrudSingleGenerator({
- targetDirectory: `${__dirname}/../generated/`,
requiredPermission: "pageTree",
})
export class Footer extends BaseEntity {}
Enable MikroORM dataloader for generated CRUD resolvers
Generated list resolvers from @comet/api-generator no longer inspect GraphQL selection sets to build populate options.
Relation loading is now expected to be handled by MikroORM's dataloader.
Enable dataloader in your MikroORM config:
+ import { DataloaderType } from "@mikro-orm/core";
export const ormConfig = createOrmConfig(
defineConfig({
// ...
+ dataloader: DataloaderType.ALL,
}),
);
Without enabling dataloader, relation fields resolved by generated resolvers can lead to significantly more SQL queries.
Update @EntityInfo decorator usage
The @EntityInfo decorator no longer accepts a TypeScript function or a service class. Migrate to the new object-based API using dot-notation field paths.
Entities using an inline function:
- @EntityInfo<News>((news) => ({ name: news.title, secondaryInformation: news.slug }))
+ @EntityInfo<News>({ name: "title", secondaryInformation: "slug" })
@Entity()
export class News { ... }
If the entity has a visibility concept, move it into the visible field using a MikroORM ObjectQuery:
- @EntityInfo<News>((news) => ({ name: news.title, secondaryInformation: news.slug }))
+ @EntityInfo<News>({ name: "title", secondaryInformation: "slug", visible: { status: { $eq: NewsStatus.active } } })
@Entity()
export class News { ... }
Dot-notation is supported for nested relations and embeddables (ManyToOne/OneToOne only):
@EntityInfo<Product>({ name: "title", secondaryInformation: "manufacturer.name", visible: { status: { $eq: ProductStatus.Published } } })
Documents using a service class (e.g. PageTreeNodeDocumentEntityInfoService):
Remove @EntityInfo(PageTreeNodeDocumentEntityInfoService) from Page, Link, and any other DocumentInterface entities. Their entity info is now derived automatically from the PageTreeNodeEntityInfo SQL view — no decorator is needed.
- @EntityInfo(PageTreeNodeDocumentEntityInfoService)
@Entity()
@ObjectType({ implements: () => [DocumentInterface] })
export class Page { ... }
Remove PageTreeNodeDocumentEntityInfoService from all NestJS module providers as well.
Entities with complex info logic (custom EntityInfoServiceInterface -> raw SQL string):
For cases where the object syntax is insufficient, e.g. cases where you used a custom EntityInfoService before, you can pass a raw SELECT statement instead.
The query must return the columns name, secondaryInformation, visible, id, and entityName:
@EntityInfo<DamFile>(`SELECT "name", "secondaryInformation", "visible", "id", 'DamFile' AS "entityName" FROM "DamFileEntityInfo"`)
Don't forget to remove all custom services that implemented EntityInfoServiceInterface as they are no longer needed:
- import { EntityInfoServiceInterface } from "@comet/cms-api";
-
- @Injectable()
- export class MyEntityInfoService implements EntityInfoServiceInterface<MyEntity> {
- async getEntityInfo(entity: MyEntity) {
- return { name: entity.title, secondaryInformation: entity.slug };
- }
- }
Fix dev dependency imports in API source code
@comet/eslint-config v9 adds the import/no-extraneous-dependencies rule to the NestJS ESLint config.
This rule prevents production source files from importing packages that are listed as devDependencies in package.json.
Dev dependencies may still be imported in test files (*.spec.ts, *.test.ts).
After upgrading, run the lint to surface any violations:
cd api
npm run lint
For each import/no-extraneous-dependencies error, move the imported package from devDependencies to the appropriate section in api/package.json:
- Packages used only as types or utilities in source code → move to
dependencies - Packages that consumers of your package are expected to install themselves → move to
peerDependencies
{
"dependencies": {
+ "some-package": "^1.0.0",
},
- "devDependencies": {
- "some-package": "^1.0.0",
- }
}
Reinstall dependencies after updating package.json:
npm install
Verify lint passes
cd api
npm run lint
Repeat this step, fixing all lint errors, until the lint passes.
Rename RedirectSourceTypeValues to `RedirectSourceType``
Use RedirectSourceType instead of RedirectSourceTypeValues from @comet/cms-api
Admin
Update Comet and peer dependencies
Update all @comet/* dependencies in admin/package.json to version 9.0.0 and update peer dependencies:
{
"dependencies": {
- "@comet/admin": "^8.0.0",
+ "@comet/admin": "9.0.0",
- "@comet/admin-date-time": "^8.0.0",
+ "@comet/admin-date-time": "9.0.0",
- "@comet/admin-icons": "^8.0.0",
+ "@comet/admin-icons": "9.0.0",
- "@comet/cms-admin": "^8.0.0",
+ "@comet/cms-admin": "9.0.0",
- "rdndmb-html5-to-touch": "^8.1.2",
+ "rdndmb-html5-to-touch": "^9.0.0",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
- "react-dnd-multi-backend": "^8.1.2",
+ "react-dnd-multi-backend": "^9.0.0",
- "react-intl": "^6.8.9",
+ "react-intl": "^7.1.9",
- "@mui/x-data-grid": "^7.29.12",
- "@mui/x-data-grid-pro": "^7.29.12",
+ "@mui/x-data-grid": "^8.27.5",
+ "@mui/x-data-grid-pro": "^8.27.5",
- "@mui/x-date-pickers": "^7.29.4",
- "@mui/x-date-pickers-pro": "^7.29.4",
- "@mui/x-license-pro": "^7.29.1",
+ "@mui/x-date-pickers": "^8.27.2",
+ "@mui/x-date-pickers-pro": "^8.27.2",
+ "@mui/x-license": "^8.26.0",
},
"devDependencies": {
- "@comet/admin-generator": "^8.0.0",
+ "@comet/admin-generator": "9.0.0",
- "@comet/cli": "^8.0.0",
+ "@comet/cli": "9.0.0",
- "@comet/eslint-config": "^8.0.0",
+ "@comet/eslint-config": "9.0.0",
- "@types/react": "^18.3.23",
- "@types/react-dom": "^18.3.7",
+ "@types/react": "^19.2.14",
+ "@types/react-dom": "^19.2.3",
}
}
Update any other @comet/* packages your project uses (e.g., @comet/admin-color-picker, @comet/admin-rte, @comet/brevo-admin) to 9.0.0 as well.
Ensure that all other dependencies are compatible with React 19.
For react-final-form, add the following overrides to your package.json:
{
+ "overrides": {
+ "react-final-form": {
+ "react": "^19.2.4"
+ },
+ "react-final-form-arrays": {
+ "react": "^19.2.4"
+ }
+ },
}
The latest Final Form version does include support for React 19. However, it was rewritten to TypeScript using AI, which introduced some incompatibilities. Since the project isn't actively maintained anymore and we're planning to switch to react-hook-form, we decided to not upgrade and override the supported React version instead.
Then, install the updated dependencies:
npm install
Replace DependencyList with DependenciesList or DependentsList
The DependencyList component has been replaced by two focused components:
DependenciesList— displays what an entity depends on (query must returnitem.dependencies)DependentsList— displays what depends on an entity (query must returnitem.dependents)
- import { DependencyList } from "@comet/cms-admin";
+ import { DependenciesList, DependentsList } from "@comet/cms-admin";
- <DependencyList query={myDependentsQuery} variables={{ id }} />
+ <DependentsList query={myDependentsQuery} variables={{ id }} />
- <DependencyList query={myDependenciesQuery} variables={{ id }} />
+ <DependenciesList query={myDependenciesQuery} variables={{ id }} />
Admin packages are now ESM-only
The admin packages now ship ESM-only builds. This should not require any significant changes if you're already using Vite. Review the Starter for an example of a Vite-based admin setup.
The only required change is to update your TSConfig's module and moduleResolution options:
{
"compilerOptions": {
- "module": "ESNext",
- "moduleResolution": "Node",
+ "module": "preserve",
+ "moduleResolution": "bundler"
}
}
This is necessary to support importing from Admin packages (e.g, import { GridCellContent } from "@comet/admin") in the Admin Generator configuration files.
Upgrade to React 19
Execute the following codemods:
cd admin
npx codemod@latest react/19/migration-recipe
npx types-react-codemod@latest preset-19 ./src
See the official React 19 migration guide for more information.
Upgrade MUI X Data Grid to v8
The MUI X Data Grid peer dependency has been bumped to v8. Review the migration guide for more information.
Run codemods
cd admin
npx @mui/x-codemod@latest v8.0.0/data-grid/preset-safe src
Import GridToolbarQuickFilter from @comet/admin
GridToolbarQuickFilter must now be imported from @comet/admin:
-import { GridToolbarQuickFilter } from "@mui/x-data-grid";
+import { GridToolbarQuickFilter } from "@comet/admin";
-import { GridToolbarQuickFilter } from "@mui/x-data-grid-pro";
+import { GridToolbarQuickFilter } from "@comet/admin";
-import { GridToolbarQuickFilter } from "@mui/x-data-grid-premium";
+import { GridToolbarQuickFilter } from "@comet/admin";
An ESLint rule enforces this import. Running npm run lint will flag any remaining usages.
Update row selection model usage
The row selection model has been changed from GridRowId[] to { type: 'include' | 'exclude'; ids: Set<GridRowId> }:
-const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>([]);
+const [selectionModel, setSelectionModel] = useState<GridRowSelectionModel>({
+ type: "include",
+ ids: new Set([]),
+});
Update all code that reads from the selection model:
-selectionModel.length
+selectionModel.ids.size
-selectionModel.includes(row.id)
+selectionModel.ids.has(row.id)
-for (const id of selectionModel) {
+for (const id of Array.from(selectionModel.ids)) {
When passing rowSelectionModel as a prop, convert to the new format:
-rowSelectionModel={state.ids}
+rowSelectionModel={{ type: "include", ids: new Set(state.ids) }}
When reading from onRowSelectionModelChange, convert back from Set to array if needed:
onRowSelectionModelChange={(newSelection) => {
- updateState({ ids: newSelection as string[] });
+ updateState({ ids: Array.from(newSelection.ids) as string[] });
}}
Add disableRowSelectionExcludeModel to opt out of the new exclude selection model:
<DataGridPro
checkboxSelection
keepNonExistentRowsSelected
+ disableRowSelectionExcludeModel
/>
Provide the DataGrid component via CometConfig
The CometConfig context now exposes the DataGrid component used by @comet/cms-admin's DataGrid wrapper.
If you use DataGridPro/DataGridPremium, provide it in your CometConfigProvider:
import { DataGridPro } from "@mui/x-data-grid-pro";
import { CometConfigProvider } from "@comet/cms-admin";
<CometConfigProvider
dataGrid={{ component: DataGridPro }}
// ...rest of config
>
{children}
</CometConfigProvider>;
Update LicenseInfo import
In MUI X v8, LicenseInfo is now exported from @mui/x-license:
-import { LicenseInfo } from "@mui/x-license-pro";
+import { LicenseInfo } from "@mui/x-license";
Upgrade MUI X Date Pickers to v8
The MUI X Date Pickers peer dependency has been bumped to v8. Review the migration guide for more information.
Update the AdapterDateFns import
-import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
+import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
Replace the variant prop with color in Tooltip
The variant prop has been renamed to color and the options neutral and primary have been removed.
Change the usage of variant to color and remove or replace the values neutral and primary.
Example:
<Tooltip
title="Title"
- variant="light"
+ color="light"
>
<Info />
</Tooltip>
<Tooltip
title="Title"
- variant="neutral"
>
<Info />
</Tooltip>
Replace createHttpClient with native fetch
The createHttpClient function has been removed. Use native fetch instead.
Remove clearable prop from Autocomplete, FinalFormInput, FinalFormNumberInput and FinalFormSearchTextField
Those fields are now clearable automatically when not set to required, disabled or readOnly.
Remove hasClearableContent prop from ClearInputAdornment
The hasClearableContent prop has been removed from ClearInputAdornment. The component now always renders when included in the component tree.
Callers should conditionally render the component instead of passing the hasClearableContent prop.
Before:
<ClearInputAdornment
position="end"
hasClearableContent={Boolean(value)}
onClick={() => onChange("")}
/>
After:
{
value && <ClearInputAdornment position="end" onClick={() => onChange("")} />;
}
Replacement of @comet/admin-date-time
Most components of @comet/admin-date-time are now deprecated and are being replaced by new components in @comet/admin.
Use the new components from @comet/admin (recommended)
In most cases, the new components will be a drop-in replacement for the legacy components, so you can simply replace the imports:
Legacy component from @comet/admin-date-time | New component from @comet/admin |
|---|---|
DatePicker | DatePicker |
DateField | DatePickerField |
FinalFormDatePicker | DatePickerField (without using <Field />) |
DateRangePicker | DateRangePicker |
DateRangeField | DateRangePickerField |
FinalFormDateRangePicker | DateRangePickerField (without using <Field />) |
TimePicker | TimePicker |
TimeField | TimePickerField |
FinalFormTimePicker | TimePickerField (without using <Field />) |
DateTimePicker | DateTimePicker |
DateTimeField | DateTimePickerField |
FinalFormDateTimePicker | DateTimePickerField (without using <Field />) |
-import { DatePicker } from "@comet/admin-date-time";
+import { DatePicker } from "@comet/admin";
-import { DateField } from "@comet/admin-date-time";
+import { DatePickerField } from "@comet/admin";
When using final-form, only the field-components are available for the new components. Therefore, usage of the FinalForm* components with <Field /> must be updated to use the respective *Field directly, without using <Field />.
-import { Field } from "@comet/admin";
-import { FinalFormDatePicker } from "@comet/admin-date-time";
+import { DatePickerField } from "@comet/admin";
export const ExampleFields = () => {
return (
<>
- <Field component={FinalFormDatePicker} name="date" label="Date Picker" />
+ <DatePickerField name="date" label="Date Picker" />
</>
);
};
Continue using the deprecated components
The legacy components will continue to work as they did previously. The only change is that the class-names and theme component-keys are now prefixed with "Legacy".
Update any use of class-names of the component's slots:
CometAdminDatePicker-*->CometAdminLegacyDatePicker-*CometAdminDateRangePicker-*->CometAdminLegacyDateRangePicker-*CometAdminDateTimePicker-*->CometAdminLegacyDateTimePicker-*CometAdminTimePicker-*->CometAdminLegacyTimePicker-*
const WrapperForStyling = styled(Box)(({ theme }) => ({
- ".CometAdminDatePicker-calendar": {
+ ".CometAdminLegacyDatePicker-calendar": {
backgroundColor: "magenta",
},
}));
Update the component-keys when using defaultProps or styleOverrides in the theme:
CometAdminDatePicker->CometAdminLegacyDatePickerCometAdminDateRangePicker->CometAdminLegacyDateRangePickerCometAdminDateTimePicker->CometAdminLegacyDateTimePickerCometAdminTimePicker->CometAdminLegacyTimePicker
export const theme = createCometTheme({
components: {
- CometAdminDatePicker: {
+ CometAdminLegacyDatePicker: {
defaultProps: {
startAdornment: <FancyCalendarIcon />,
},
},
},
});
Update usages of "Future" (now stable) components
The "Future" prefix has been removed from date/time components that are now considered stable.
If already in use, update the imports of these components and their types:
DatePicker:
Future_DatePicker->DatePickerFuture_DatePickerProps->DatePickerPropsFuture_DatePickerClassKey->DatePickerClassKeyFuture_DatePickerField->DatePickerFieldFuture_DatePickerFieldProps->DatePickerFieldProps
DateRangePicker:
Future_DateRangePicker->DateRangePickerFuture_DateRangePickerProps->DateRangePickerPropsFuture_DateRangePickerClassKey->DateRangePickerClassKeyFuture_DateRangePickerField->DateRangePickerFieldFuture_DateRangePickerFieldProps->DateRangePickerFieldProps
TimePicker:
Future_TimePicker->TimePickerFuture_TimePickerProps->TimePickerPropsFuture_TimePickerClassKey->TimePickerClassKeyFuture_TimePickerField->TimePickerFieldFuture_TimePickerFieldProps->TimePickerFieldProps
DateTimePicker:
Future_DateTimePicker->DateTimePickerFuture_DateTimePickerProps->DateTimePickerPropsFuture_DateTimePickerClassKey->DateTimePickerClassKeyFuture_DateTimePickerField->DateTimePickerFieldFuture_DateTimePickerFieldProps->DateTimePickerFieldProps
If your theme is using defaultProps or styleOverrides for any of these components, update their component-keys:
CometAdminFutureDatePicker->CometAdminDatePickerCometAdminFutureDateRangePicker->CometAdminDateRangePickerCometAdminFutureTimePicker->CometAdminTimePickerCometAdminFutureDateTimePicker->CometAdminDateTimePicker
If you are using class-names to access these components' slots, update them:
CometAdminFuture_DatePicker-*->CometAdminDatePicker-*CometAdminFuture_DateRangePicker-*->CometAdminDateRangePicker-*CometAdminFuture_TimePicker-*->CometAdminTimePicker-*CometAdminFuture_DateTimePicker-*->CometAdminDateTimePicker-*
Rename GraphQL operations and fragments with redundant kind suffixes
@comet/eslint-config v9 adds the @graphql-eslint/naming-convention rule from @graphql-eslint/eslint-plugin. The rule forbids GraphQL fragment, query, mutation, and subscription names that end with their own kind (e.g. FooFragment, BarQuery), which would otherwise produce duplicated suffixes such as FragmentFragment or QueryQuery in generated TypeScript types.
After upgrading, run the lint to surface any violations:
cd admin
npm run lint
For each @graphql-eslint/naming-convention error, rename the operation/fragment to drop the redundant suffix and update any generated TypeScript type references accordingly:
const attributesFragment = gql`
- fragment BrevoContactAttributesFragment on BrevoContact {
+ fragment BrevoContactAttributes on BrevoContact {
...
}
`;
- import type { GQLBrevoContactAttributesFragmentFragment } from "./generated";
+ import type { GQLBrevoContactAttributesFragment } from "./generated";
After renaming, re-run code generation to update the *.generated.ts files.
Verify lint passes
cd admin
npm run lint
Repeat this step, fixing all lint errors, until the lint passes.
Site
Update Comet and peer dependencies
Update all @comet/* dependencies in site/package.json to version 9.0.0 and update peer dependencies:
{
"dependencies": {
- "@comet/site-nextjs": "^8.0.0",
+ "@comet/site-nextjs": "9.0.0",
- "@next/bundle-analyzer": "^14.2.30",
+ "@next/bundle-analyzer": "^16.1.6",
- "next": "^14.2.30",
+ "next": "^16.1.6",
- "react": "^18.3.1",
- "react-dom": "^18.3.1",
+ "react": "^19.2.0",
+ "react-dom": "^19.2.0",
- "react-intl": "^6.8.9",
+ "react-intl": "^7.1.9",
},
"devDependencies": {
- "@comet/cli": "^8.0.0",
+ "@comet/cli": "9.0.0",
- "@comet/eslint-config": "^8.0.0",
+ "@comet/eslint-config": "9.0.0",
- "@types/react": "^18.3.23",
- "@types/react-dom": "^18.3.7",
+ "@types/react": "^19.2.0",
+ "@types/react-dom": "^19.2.0",
}
}
Ensure that all other dependencies are compatible with React 19 and Next.js 16.
After updating the dependencies, remove node_modules/ and package-lock.json (or your lock file) before reinstalling to avoid peer dependency conflicts:
rm -rf site/node_modules site/package-lock.json
npm install
Add next-env.d.ts to .gitignore
git rm site/next-env.d.ts
echo "next-env.d.ts" >> site/.gitignore
Add next typegen to lint:tsc script
This is necessary for the lint to work during CI.
{
"scripts": {
- "lint:tsc": "tsc --project ."
+ "lint:tsc": "npx next typegen && tsc --project ."
}
}
Now, execute npx next typegen once to generate the necessary types.
Remove eslint from the Next.js config file
const nextConfig: NextConfig = {
/* ... */,
- eslint: {
- ignoreDuringBuilds: process.env.NODE_ENV === "production",
- },
};
Remove deprecated experimental.instrumentationHook
In Next.js 16, the instrumentation hook is built in and the experimental flag is no longer valid. Leaving it in place logs a deprecation warning on every start.
const nextConfig: NextConfig = {
experimental: {
- instrumentationHook: true,
optimizePackageImports: ["@comet/site-nextjs"],
},
};
Update tsconfig.server.json
If you have a separate tsconfig.server.json for server.ts / tracing.ts / cache-handler.ts that sets module: "commonjs", you must also override moduleResolution.
Otherwise tsc fails with TS5095: Option 'bundler' can only be used when 'module' is set to 'preserve' or to 'es2015' or later — because Next 16 enforces moduleResolution: "bundler" in the base tsconfig.json and rewrites it back if you change it.
{
"compilerOptions": {
"module": "commonjs",
+ "moduleResolution": "node",
// ...
},
"extends": "./tsconfig.json"
}
Disable Turbopack
Our site packages currently aren't compatible with Turbopack. Disable Turbopack until this is resolved:
- const app = next({ dev, hostname: host, port });
+ const app = next({ dev, hostname: host, port, webpack: true });
{
"scripts": {
- "build": "run-s intl:compile && run-p gql:types generate-block-types css:types build-server && next build"
+ "build": "run-s intl:compile && run-p gql:types generate-block-types css:types build-server && next build --webpack"
}
}
Upgrade to React 19
Execute the following codemods:
cd site
npx codemod@latest react/19/migration-recipe
npx types-react-codemod@latest preset-19 ./src
See the official React 19 migration guide for more information.
Change to Next.js Async Request APIs
Multiple Next.js APIs are now asynchronous and must be awaited. This applies to:
headers()cookies()draftMode()paramsandsearchParamson pages, layouts, and route handlers
Update your usages to support the asynchronous APIs. Use the new props helper types. Review the migration guide for more information.
Examples
- const isDraftModeEnabled = draftMode().isEnabled;
+ const isDraftModeEnabled = (await draftMode()).isEnabled;
- const cookieStore = cookies();
+ const cookieStore = await cookies();
- type PageProps = {
- params: { path: string[]; domain: string; language: string; visibility: VisibilityParam };
- };
- export default async function Page({ params }: PageProps) {
+ export default async function Page({ params }: PageProps<"/[visibility]/[domain]/[language]/[[...path]]">) {
- setVisibilityParam(params.visibility);
- const scope = { domain: params.domain, language: params.language };
+ const { visibility, domain, language } = await params;
+ setVisibilityParam(visibility as VisibilityParam);
+ const scope = { domain, language };
}
- async function fetchPageTreeNode(params: { path: string[]; domain: string; language: string }) {
+ async function fetchPageTreeNode(params: PageProps<"/[visibility]/[domain]/[language]/[[...path]]">["params"]) {
- const siteConfig = getSiteConfigForDomain(params.domain);
+ const { domain, language, path: pathParam } = await params;
+ const siteConfig = getSiteConfigForDomain(domain);
- const path = `/${(params.path ?? []).join("/")}`;
- const { scope } = { scope: { domain: params.domain, language: params.language } };
+ const path = `/${(pathParam ?? []).join("/")}`;
+ const { scope } = { scope: { domain, language } };
- interface LayoutProps {
- params: { domain: string; language: string };
- }
- export default async function Layout({ children, params: { domain, language } }: PropsWithChildren<LayoutProps>) {
+ export default async function Layout({ children, params }: LayoutProps<"/[visibility]/[domain]/[language]">) {
+ const { domain, language: languageParam } = await params;
const siteConfig = getSiteConfigForDomain(domain);
+ let language = languageParam;
if (!siteConfig.scope.languages.includes(language)) {
language = "en";
}
Rename middleware.ts to proxy.ts
mv site/src/middleware.ts site/src/proxy.ts
Next.js 16 requires proxy.ts to export a function named proxy (or a default export).
The previously exported middleware function must be renamed — otherwise every request fails at runtime with must export a function named "proxy" or a default function:
- export async function middleware(request: NextRequest) {
+ export async function proxy(request: NextRequest) {
// ...
}
If you're using Knip, you may need to add proxy.ts as entry point:
{
"$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.
The full reference implementation is in the demo: https://github.com/vivid-planet/comet/blob/main/demo/site/src/middleware/redirectToMainHost.ts. The essential steps are outlined below.
1. Add a DomainRedirects query
Query paginatedRedirects with sourceType: { equal: "domain" } to fetch all admin-configured domain redirects for a given scope:
import { gql } from "@comet/site-nextjs";
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
}
}
`;
Note: the new RedirectFilter.sourceType: RedirectSourceTypeEnumFilter input and the domain value on the RedirectSourceTypeValues enum are part of the regenerated v9 schema — make sure your project has regenerated schema.gql / GraphQL types before running the codegen for this query.
2. Add an in-memory cache for the redirects
Domain redirects are hit on every non-resolving request, so cache them in memory. Using cache-manager with a keyv store:
npm install cache-manager keyv
import { createCache } from "cache-manager";
import Keyv from "keyv";
export const memoryCache = createCache({
stores: [new Keyv()],
ttl: 15 * 60 * 1000, // 15 minutes
refreshThreshold: 5 * 60 * 1000, // refresh if less than 5 minutes TTL are remaining
});
memoryCache.on("refresh", ({ error }) => {
if (error) {
console.error("Error refreshing cache in background", error);
}
});
3. Resolve redirect targets to destination URLs
The admin stores the redirect target as a RedirectsLinkBlock which can point to an internal page, an external URL, or (if your project has it) a news item. Add a helper that turns the stored block into a fully-qualified URL:
import {
type ExternalLinkBlockData,
type InternalLinkBlockData,
type RedirectsLinkBlockData,
} from "@src/blocks.generated";
import { createSitePath } from "@src/util/createSitePath";
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 })}`;
}
break;
}
case "external":
return (block.props as ExternalLinkBlockData).targetUrl;
}
return undefined;
}
Extend the switch with a case "news" (or any other link types your project registers) as needed.
4. Update the middleware to look up domain redirects
Before falling back to "redirect to main host", check whether there is a domain redirect for the current host — both when the host matches one of the site-config's additional/pattern domains and when it doesn't match any site-config at all. Detect redirect loops, since misconfigured redirects (e.g. pointing a host at itself) would otherwise 301 in a tight loop.
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";
function normalizeHost(value: string): string {
return value.replace(/^https?:\/\//, "");
}
async function fetchDomainRedirects(scope: { domain: string }) {
return memoryCache.wrap(`domainRedirects-${scope.domain}`, async () => {
const graphQLFetch = createGraphQLFetch();
// ... paginate through paginatedRedirects with filter: { sourceType: { equal: "domain" } }
// and return the collected nodes
});
}
async function fetchDomainRedirectsForAllScopes() {
return (
await Promise.all(getSiteConfigs().map((config) => fetchDomainRedirects(config.scope)))
).flat();
}
export function withRedirectToMainHostMiddleware(middleware: CustomMiddleware) {
return async (request: NextRequest) => {
const host = getHostByHeaders(request.headers);
const siteConfig = await getSiteConfigForHost(host);
if (!siteConfig) {
const redirectSiteConfig =
getSiteConfigs().find((c) => matchesHostWithAdditionalDomain(c, host)) ||
getSiteConfigs().find((c) => matchesHostWithPattern(c, host));
if (redirectSiteConfig) {
// 1. First, check for an admin-configured domain redirect for this scope
const domainRedirects = await fetchDomainRedirects(redirectSiteConfig.scope);
const redirect = domainRedirects.find(
(r) => normalizeHost(r.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 });
}
}
// 2. Otherwise, redirect to the main host (previous behavior)
const mainHost = normalizeHost(redirectSiteConfig.domains.main);
if (mainHost === normalizeHost(host)) {
throw new Error(
`Redirect loop detected: main host ${mainHost} equals current host`,
);
}
return NextResponse.redirect(
`https://${redirectSiteConfig.domains.main}${request.nextUrl.pathname}${request.nextUrl.search}`,
{ status: 301 },
);
}
// 3. Host doesn't match any site-config — check cross-scope domain redirects as a last resort
const domainRedirects = await fetchDomainRedirectsForAllScopes();
const redirect = domainRedirects.find(
(r) => normalizeHost(r.source) === normalizeHost(host),
);
if (redirect) {
const scopedSiteConfig = getSiteConfigs().find(
(c) => c.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 });
}
return middleware(request);
};
}
createGraphQLFetch must be callable from a proxy. In Next.js 16 the proxy runs in the Node.js runtime by default, so a non-edge createGraphQLFetch works here. If your project previously had an edge-runtime guard in createGraphQLFetch (e.g. a throw when process.env.NEXT_RUNTIME === "edge"), you can remove it — or call the fetch inside memoryCache.wrap so the throw only fires on a real edge deploy.
Add cache: "force-cache" to GraphQL fetch
Next.js no longer caches fetch requests by default.
Review the migration guide for more information.
Add cache: "force-cache" to createGraphQLFetch().
The file might be named differently in some Comet projects (e.g. createGraphQLFetch.ts):
export function createGraphQLFetch() {
// ...
return createGraphQLFetchLibrary(
createFetchWithDefaults(createFetchWithDefaultNextRevalidate(fetch, 7.5 * 60), {
+ cache: "force-cache",
headers: {
// ...
},
}),
`${process.env.API_URL_INTERNAL}/graphql`,
);
}
Import server-only modules from @comet/site-nextjs/server
Server-only exports (sitePreviewRoute, legacyPagesRouterSitePreviewApiHandler, previewParams, legacyPagesRouterPreviewParams, persistedQueryRoute) have been moved from @comet/site-nextjs to @comet/site-nextjs/server.
This prevents server-only code (which depends on next/headers, fs/promises, server-only, etc.) from being pulled into client bundles. Previously, tree-shaking would remove unused server code, but this is an optional optimization — for example, Vite's dev server does not tree-shake, causing errors when importing @comet/site-nextjs in non-server environments (e.g., Storybook).
Update all imports in your site that use these functions:
- import { sitePreviewRoute } from "@comet/site-nextjs";
+ import { sitePreviewRoute } from "@comet/site-nextjs/server";
- import { previewParams } from "@comet/site-nextjs";
+ import { previewParams } from "@comet/site-nextjs/server";
- import { legacyPagesRouterPreviewParams } from "@comet/site-nextjs";
+ import { legacyPagesRouterPreviewParams } from "@comet/site-nextjs/server";
- import { legacyPagesRouterSitePreviewApiHandler } from "@comet/site-nextjs";
+ import { legacyPagesRouterSitePreviewApiHandler } from "@comet/site-nextjs/server";
- import { persistedQueryRoute } from "@comet/site-nextjs";
+ import { persistedQueryRoute } from "@comet/site-nextjs/server";
Similarly, if you import persistedQueryRoute directly from @comet/site-react:
- import { persistedQueryRoute } from "@comet/site-react";
+ import { persistedQueryRoute } from "@comet/site-react/server";
Rename GraphQL operations and fragments with redundant kind suffixes
@comet/eslint-config v9 adds the @graphql-eslint/naming-convention rule. See the Admin section for details and apply the same renames in site.
Verify lint passes
cd site
npm run lint
Repeat this step, fixing all lint errors, until the lint passes.