Upgrading to Contensis React Base v4
Log in to add to favouritesPage last updated 22 April 2026
📦 Migration Guide: v3 → v4
Welcome to the official upgrade guide for Contensis React Base v4.
This document covers the breaking changes, major upgrades, and migration steps needed to move from version 3 to version 4.
⚙️ Before You Start
Before upgrading to v4, first update your app to the latest version 3.x. This ensures there are no lingering issues from v3.
âś… Pre-upgrade checklist:
- Upgdate to the latest
@zengenti/contensis-react-base@^3.x - Test in both
developmentandproductionmodes - Monitor your server and browser console for warnings or errors
🚀 What’s New in v4
React: v17 → v18
- Upgraded React from v17 to v18
- SSR responses streamed by default
- adds
transfer-encoding: chunkedresponse header - enables full support for
<Suspense />in React 18
- adds
- Replaced React Hot Loader with React Fast Refresh
- You may encounter new hydration errors in SSR such as
Minified React Error #418and#423 - Deprecated support for IE11
PropTypessupport removed (ahead of React 19)
🔄 New SSR React Utilities
Import from @zengenti/contensis-react-base/util
useIsClient()to use when we need to conditionally render in the browser only<NoSSR />component can wrap other components to conditionally render in the browser only
React Router: v5 → v6
- Removed
useHistoryhook, replace withuseNavigate - Changed
staticRouteobject structure - Removed
Redirectcomponent, replace withRedirectfrom@zengenti/contensis-react-base/routing - Removed
staticContext, replace withuseHttpContexthook - Removed
exactprop in static routes (v6 handles this automatically) - Future flags included for smoother upgrade to v7
đź§ New Router Helpers
Import from @zengenti/contensis-react-base/routing
<Redirect />and<Status />JSX componentsuseHttpContext()hook (replacesstaticContext)
Express: v4 → v5
- Requires Node.js v18+ (v20 or v22 recommended)
- Route pattern matching syntax has changed
đź”— Search package tighter-coupling
- Support for setting
searchOptionsin route configurations - Return
searchOptionsfrom theonRouteLoadedrouting hook - A cleaner boilerplate-free approach
- no additional imports
- no feature reducers and sagas
- no redux injection
- no saga calls are needed
paramsprovided by default
- Trigger search by setting
facetorlistingTypeinsearchOptions - Support remains for all existing overrides
- Existing implementations continue to work without changes
- However, we recommend removing old boilerplate to simplify your codebase
- New
i18nconfiguration enables navigation with localised facet and filter keys
🌍 i18n
- Localisation / translation / internationalization native support
- Configure and switch between locales
- Translate static elements
- Resolve and navigate to translated versions of content
đź“– i18n documentation
🚨 Breaking Changes Overview
| Area | Change |
|---|---|
| Node.js | Requires Node v18+ (v22 recommended) |
| React | React Hot Loader removed; React Fast Refresh now required |
| Router | useHistory() hook replaced with useNavigate() |
| Router | staticRoute structure changed |
| Router | Redirect component removed, replace with Redirect routing helper |
| Router | staticContext removed, use useHttpContext() |
| Express | Route matching syntax changed |
| Express | Removed deprecated method signatures |
| Compatibility | IE11 support deprecated |
| Compatibility | Legacy bundle support deprecated |
| State Management | Redux with Immutable.js deprecated |
| State Management | Default stateType now is plain JS (via Immer) |
| Forms | Removed /forms, use @contensis/forms instead |
đź”§ Step-by-Step Migration
1. Upgrade Node.js
Update your local working copy of Node.js using your preferred method
We recommend you upgrade to version 22 (or 20 as a minimum)
Example: check your current Node.js version
node -v
# outputs version to console
v22.17.1
Update other references to Node.js throughout the app
- any
*.Dockerfile- update the tag number in theFROMdocker image .nvmrcfile - update the number in here- any webpack config (loaders) that target a specific
nodeversion
2. Install React Base v4
npm install @zengenti/contensis-react-base
Troubleshooting: If you encounter new Could not resolve dependency errors that prevent the install from completing, use:
npm install @zengenti/contensis-react-base --legacy-peer-deps
More help: Avoid using "force" or "legacy-peer-deps"
3. Replace React Hot Loader with Fast Refresh
- Uninstall
@hot-loader/react-dom:
npm uninstall @hot-loader/react-dom
- Remove
react-domalias in Webpack configwebpack.base.config.js:
alias: {
// ... other import aliases,
'react-dom': '@hot-loader/react-dom', // ❌ delete this alias
}
- Remove
'react-hot-loader/babel'frombabel.config.js - Search your project and remove all
hot(module)usage:
import { hot } from 'react-hot-loader'; // ❌ delete this import
export default hot(module)(AppRoot); // ❌ delete this reference
export default AppRoot; // âś… replace default export
- Install Fast Refresh:
npm install --save-dev @pmmmwh/react-refresh-webpack-plugin react-refresh
- Add a new plugin to
webpack.dev.config.js:
const ReactRefreshPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
plugins: [
new ReactRefreshPlugin(), // âś… add required plugin
];
4. Upgrade React Router
- Remove references to legacy router packages:
npm uninstall react-router react-router-dom react-router-config
npm uninstall @types/react-router @types/react-router-dom @types/react-router-config
- Search your project for
useHistoryand replace with withuseNavigate:
// ❌ old
const history = useHistory();
history.push('/search');
// âś… new
const navigate = useNavigate();
navigate('/search');
Example: A typical usage of useHistory inside a React component with user input updating the state and a submit action that navigates to a new location
import { useHistory } from 'react-router-dom'; // ❌ old
import { useNavigate } from 'react-router-dom'; // âś… new
...
const [term, setTerm] = useState('');
const history = useHistory(); // ❌ old
const navigate = useNavigate(); // âś… new
const submitForm = (event) => {
event.preventDefault();
if (!term) {
history.push('/search'); // ❌ old
navigate('/search'); // âś… new
} else {
history.push('/search?term=' + term); // ❌ old
navigate('/search?term=' + term); // âś… new
}
};
The navigate function accepts options that provide equivalent functionality to other history methods such as replace() - useNavigate API documentation.
- Search your project for
staticRouteand refactor code to follow the new structure
Example: The old staticRoute format from react-router-config v5
{
staticRoute: {
route: {
path: '/pricing',
exact: true,
projects: [
'contensis'
],
component: null
},
match: {
path: '/pricing',
url: '/pricing',
isExact: true,
params: {}
}
}
}
Changed: The new staticRoute format from react-router v6
{
staticRoute: {
params: {},
pathname: '/pricing',
pathnameBase: '/',
route: {
index: true,
element: null,
fetchNode: true
}
}
}
- Search your project for
staticContextand replace withuseHttpContext()
Example: a router component that references staticContext prop
import { Route } from 'react-router-dom';
export const Status = ({ code, children }) => {
return (
<Route
render={({ staticContext }: RouteComponentProps) => {
if (staticContext) staticContext.statusCode = code;
return children;
}}
/>
);
};
Change: using the new useHttpContext() context hook instead
import { useHttpContext } from '@zengenti/contensis-react-base/routing';
export const Status = ({ code, children }) => {
const httpContext = useHttpContext();
if (httpContext) {
httpContext.statusCode = code;
}
return <>{children}</>;
};
- Search your project for
Redirectcomponent usage and update imports
Change: any component that renders a Redirect component
import { Redirect } from 'react-router-dom'; // ❌ Delete this (React Router v5)
import { Redirect } from '@zengenti/contensis-react-base/routing'; // âś… New
- Remove
exactprop from static routes
Example: StaticRoutes.ts|js file
const staticRoutes: StaticRoute[] = [
{
path: '/search/:facet?',
exact: false, // ❌ delete this
component: SearchPage,
},
];
- Search your project for
withRouterand remove any references, replace any usage of props provided bywithRouterHOC with hooks.
Example: remove withRouter higher-order-component and refactor usage of the previously provided props
import { withRouter } from 'react-router-dom'; // ❌ Delete this (React Router v5)
import { useParams, useLocation, useNavigate } from 'react-router-dom'; // âś… New (if required)
const Search = ({
match, // ❌ Delete prop
location, // ❌ Delete prop
history, // ❌ Delete prop
...otherProps
}) => {
const { id } = match.params; // ❌ Old
const { id } = useParams(); // âś… Replaces match.params prop
const location = useLocation(); // âś… Replaces location prop
const navigate = useNavigate(); // âś… Replaces history prop
};
export default withRouter(Search); // ❌ Old export
export default Search; // âś… New export
ℹ️ If your component does not use any of the match, location, or history props you only need to remove the references to withRouter
Should you encounter any further issues, refer to the official React Router v6 Migration Guide
5. Update Express wildcard paths
Search your entire project for references to app.
Carefully examine any references for wildcard paths, or deprecated syntax
Example: Wildcard reference becomes a named parameter {*splat}
// ❌ Old
app.get('/example/*', handler);
app.get('*.aspx', handler);
// âś… New
app.get('/example/{*splat}', handler);
app.get('{*splat}.aspx', handler);
Upgrade any @types package in your package.json to the latest for v5
npm install --save-dev @types/express@latest
A complete list of the changes introduced in Express v5 can be found in the official Express 5 Migration Guide
6. Update stateType
Since v3 we can set a stateType to be immutable (default) or js, so we could optionally use Immer to manage the immutability of our Redux state with plain js objects.
In v4, immutable is no longer the default stateType and the option has also been marked as deprecated. We will be removing this support for Immutable.js in a future release.
Projects using Immutable state
Change: client-entrypoint.ts and make the same change in server.ts
const config: AppConfig = {
routes: {
ContentTypeMappings: contentTypeRoutes,
StaticRoutes: staticRoutes,
},
stateType: 'immutable', // âś… add to both client & server
withReducers,
withSagas,
withEvents,
};
new ClientApp(ReactApp, config);
Projects using Immer / plain JS state
Change: client-entrypoint.ts and make the same change in server.ts
const config: AppConfig = {
routes: {
ContentTypeMappings: contentTypeRoutes,
StaticRoutes: staticRoutes,
},
stateType: 'js', // ❌ can now be removed from both client & server
withReducers,
withSagas,
withEvents,
};
new ClientApp(ReactApp, config);
7. Refactor search implementation
Although these changes strictly aren't required for the upgrade, we recommend removing old boilerplate to simplify your codebase and expand your search or listings using these simpler approaches.
Example: withEvents.ts
Your version of this file will contain all elements from this example but your exact implementation could be different. We should delete any code-blocks that resemble those in this example - they are the excess boilerplate.
onRouteLoaded: function* onRouteLoaded({
params, // ℹ️ params are now provided automatically
}) {
// ❌ delete this as we can set this in `searchOptions` in our route configurations
const listingType =
staticRoute?.route?.listingType || contentTypeListings[contentTypeId];
// ❌ delete this entire `if` block
if (path.startsWith('/search') || listingType) {
// ℹ️ search assets are now injected automatically
const { routeParams, setRouteFilters, mappers } =
(yield injectSearchAssets()) as InjectSearchAssets;
const params = routeParams(staticRoute, location); // ❌ delete this
// 🚨 if you are overriding params you will need to retain this logic
params.override = 'myvalue';
// ℹ️ no more saga calls, remember to clean up any unused imports and arguments...
yield call(setRouteFilters, {
listingType,
mappers,
params,
ssr,
});
}
// âś… `searchOptions` replaces everything we have deleted
// đź’ˇ `searchOptions` in route configurations will complement the options set here
return yield {
searchOptions: {
// Optional: provide search config here to dynamically inject the search reducer
config,
// supply your mappers here, or in your route configuration
mappers,
// Optional: set paths here if you like or update your route configurations to use search when the route is matched
onPaths: ['/en-gb/search'],
// Optional: add params if you are doing any overrides above
params,
},
};
}
ℹ️ Supply config and mappers here to have them automatically applied any time search is invoked
đź’ˇ Search is not invoked until a facet or a listingType is present in the matched route's configuration, or we have set one of those values here ourselves
If you have provided a config option to a searchOptions object, we no longer need to inject or add the search reducer and sagas to the route configuration
Change: staticRoutes.ts also update any contentTypeMappings.ts or contentTypeRoutes.ts
const staticRoutes: StaticRoute[] = [
{
path: '/search/:facet?',
component: SearchPage,
injectRedux: injectSearch, // ❌ delete this
searchOptions: {
facet: 'all', // âś… `facet` in `searchOptions` will invoke the relevant search config
},
},
{
path: '/news',
component: NewsPage,
injectRedux: injectSearch, // ❌ delete this
listingType: 'news', // ❌ this worked previously as we were plumbing everything in ourselves
searchOptions: {
listingType: 'news', // âś… `listingType` in `searchOptions` will invoke the relevant search config
},
},
{
path: '/course/:subject',
component: CoursePage,
searchOptions: {}, // ℹ️ `searchOptions` will ensure search config is available when using minilists
},
];
Change: reducers.ts
These deletions assume you've moved and no longer need the search config, reducers and sagas available in every route.
If your project uses search minilists in many or all of your routes, these are still the recommended locations to register them. Review your implementation before proceeding.
import { reducer as SearchReducer } from '@zengenti/contensis-react-base/search'; // ❌ Delete if not required on every route
import { config } from '~/components/search'; // 🚨 Delete if search feature is not needed everywhere
import SiteConfigReducer from '~/core/redux/siteConfig/reducers';
const featureReducers = {
search: SearchReducer(config), // ❌ Delete if not required on every route
siteConfig: SiteConfigReducer,
};
export default featureReducers;
Change: sagas.ts
import { sagas as searchSagas } from '@zengenti/contensis-react-base/search'; // ❌ Delete if not required on every route
import { SiteConfigSagas } from './siteConfig/sagas';
const featureSagas = [
...searchSagas, // ❌ Delete if not required on every route
...SiteConfigSagas,
];
export default featureSagas;
đź§ Known Issues & Debug Tips
1. Storybook + React 18
Some Storybook packages may request React 17 as a peer dependency, leading to version mismatches.
It is recommended to upgrade Storybook packages to support React 18.
🛠️ Workaround: Add overrides section in package.json
"overrides": {
"react": "^18.3.1",
"react-dom": "^18.3.1"
}
2. Missing Dependencies
When starting your project for the first time, if you encounter errors:
Module not found: Can't resolve '<package-name>'
Key dependencies like react-redux, or styled-components may not be installed to the project root, instead nested into another package's node_modules. Each project is slightly different in their implementation so the actual package name(s) will be revealed when you start the project.
Try:
npm install <package-name>
# install will add the dependency to your `package.json`
# and should "hoist" the package to the root `./node_modules`
npm uninstall <package-name>
# uninstall will remove the dependency that was added to your `package.json`
# but the package should remain installed in the root `./node_modules`
Confirm the dependency remains installed within the root ./node_modules
If after this the dependency is still not available in the root ./node_modules, apply the above workaround, adding the problem package to the overrides section in package.json.
3. SSR Hydration Errors (Minified React Error #418, #423 or #425)
🛠️ Debug hydration errors introduced in React 18:
- Build the project with React running in
developmentmode to output detailed errors
const CLIENT_PROD_CONFIG = {
// mode: 'production',
mode: 'development', // ⚠️ change to development (temporarily)
// ... other existing config
};
- Some hydration error examples:
- Error: Hydration failed because the initial UI does not match what was rendered on the server.
- Error: There was an error while hydrating. Because the error happened outside of a Suspense boundary, the entire root will switch to client rendering.
- Warning: Expected server HTML to contain a matching
<h1>in<div> - Warning:
<some-content>did not match. Server: "carousel-1e16efd402a4f" Client: "carousel-a4cb44040522e8" - Warning: An error occurred during hydration. The server HTML was replaced with client content in
<div>
- Simplify the component by commenting out sections and reintroduce code gradually to pinpoint issues that are difficult to trace
- If all else fails comment out the entire component
- Simply
return <p>Hello world</p>;instead
- Avoid dynamic or lazy-loaded components where hydration fails
- Change
componentin yourStaticRouteorContentTypeMapping - Do not use a
loadable()import - Import the actual component into the route configuration directly
- Change
- Ensure
@loadable/babel-pluginis applied in both client and server builds.
const CLIENT_PROD_CONFIG = {
// ... other existing config
module: {
rules: [
// âś… Webpack loader rule enables @loadable/components
// and @loadable/server to work together correctly
{
test: /\.(t|j)sx?$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
plugins: ['@loadable/babel-plugin'],
},
},
},
],
},
};
4. Resolve hydration errors with Client rendering
ℹ️ useIsClient hook provides a hydration-safe approach to defer component renders
ℹ️ Wrap components with <NoSSR /> to achieve the same defer behaviour
đź’ˇ loadable() lazy loaded components accept an option to avoid SSR, as well as options available in the route component mappings
There are many articles online that explain hydration errors and offer suggestions as to what we might be doing wrong to cause a mismatch between the server rendered HTML and the client rendered HTML
In some cases the solution (and the problem) could be deferring the render of the component to the client only
With previous React releases, we might have written conditional code like if (typeof window !== 'undefined').
With new hydration checks in React 18, if we are intentionally rendering different content on the server and the browser, or deferring rendering to client-side only we must tread carefully in order to avoid the dreaded Minifed React Errors...
A hydration-safe approach to defer component rendering would be to use a hook that relies on useEffect being an api that is called only when the component has mounted in the browser
Example: useIsClient hook that can be used to prevent rendering in SSR
export const useIsClient = () => {
const [isClient, setIsClient] = useState(false);
useEffect(() => setIsClient(true), []);
return isClient;
};
useIsClient hook and NoSSR component are available in @zengenti/contensis-react-base/util
Example: Import and use the useIsClient hook
import { useIsClient } from '@zengenti/contensis-react-base/util';
export default Page = () => {
const isClient = useIsClient();
if (!isClient) return null; // Skip SSR rendering
return (
<div>
<h1>My Page</h1>
<div>This page only renders on the client</div>
</div>
);
};
Example: Wrap JSX with the NoSSR component
import { NoSSR } from '@zengenti/contensis-react-base/util';
export default Page = () => {
return (
<div>
<h1>My Page</h1>
<NoSSR>
<div>This part only renders on the client</div>
</NoSSR>
</div>
);
};
Example: Avoid SSR in loadable() component imports
export const Content = loadable<any>(
() =>
import(
/* webpackChunkName: "content.template" */ '~/templates/content/content.template'
),
{ ssr: false } // âś… add this option
);
5. Try out React 19
Add overrides to your package.json to force your project to install React v19
"overrides": {
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
ℹ️ Verify the rendered by react-dom version in React Developer Tools → Components view