Some Links are Rendering as URL Encoded
During a Sitecore XM Cloud migration using Next.js, we found that some links were returning HTTP 404, not found. While there can be many reasons for this symptom, it was the first time I encountered this specific one. The query strings and hash values were not rendering as expected in the DOM. They were URL encoded. Their question mark (?
) were rendered as %3F
, &
as %26
, and hash sign (#
) as %23
.
Example: <a href="/search%3Futm_source=website%23filter=product">Browse all products</a>
This caused the page path to be /search%3Futm_source=website%23filter=product
instead of just /search
. This also caused both the query string and the hash to be empty.
What is Causing Links to be URL Encoded in the DOM?
The first step was to check the field in Sitecore. I found out that it was an internal link with a relative URL set as an external link. The URL was not URL encoded at this level.
The next step was getting the field JSON value. Nothing seemed wrong with it and it was not URL encoded before being passed to the JSS Link
component.
{
"value": {
"href": "/search?utm_source=website#filter=product",
"text": "Browse all products",
"linktype": "external",
"url": "/search?utm_source=website#filter=product",
"anchor": "",
"target": ""
}
}
Then, I looked at the Sitecore JSS Link
component code where I found an interesting fact. It executes this logic (simplified for the example):
import NextLink from 'next/link';
// Regex to match strings that start with a forward slash / (relative URLs)
const internalLinkMatcher = /^\//g,
// Destructure 3 properties of the field value
const { href, querystring, anchor } = value;
// If the href is an internal route, return a next/link component.
// Set its href using the 3 destructured properties.
if (internalLinkMatcher.test(href)) {
return (
<NextLink
href={{ pathname: href, query: querystring, hash: anchor }}
// ...
>
// ...
</NextLink>
);
}
This code works well for internal links which have these “Query string” and “Anchor” parameters:
However, external links do not have those properties in Sitecore as we could see in the first screenshot and the field JSON value. This means that the next/link
href
prop for my example link was href={{ pathname: "/search?utm_source=website#filter=product", query: "", hash: "" }}
. I guess that somewhere, Next.js must URL encode the pathname
property value.
What Are the Possible Solutions?
Educate Content Authors to Use the Right Link Type
If you have only a few affected links, it would be easier to edit those in Sitecore. You should train the content authors to understand the differences between an internal and an external link, when to use which, and how to fix incorrect ones by themselves.
Create a Link Wrapper Component
If you are dealing with a lot of existing links, the best approach would be a link wrapper component.
Equipped with the knowledge of how the JSS SDK handles the field in its Link
component, I implemented a wrapper that fixes the issue by moving the hash and the query string from the external link href
property value to the proper anchor
and querystring
properties.
The wrapper modifies the field JSON value to get this output:
{
"value": {
"href": "/search",
"text": "Browse all products",
"linktype": "external",
"url": "/search?utm_source=website#filter=product",
"anchor": "filter=product",
"querystring": "utm_source=website"
"target": ""
}
}
Then, the next/link
href
prop becomes href={{ pathname: "/search", query: "utm_source=website", hash: "filter=product" }}
and the link in the DOM becomes <a href="/search?utm_source=website#filter=product">Browse all products</a>
as expected.
Here is the code of the component:
import {
LayoutServicePageState,
Link,
LinkField,
LinkFieldValue,
LinkProps,
useSitecoreContext,
} from '@sitecore-jss/sitecore-jss-nextjs';
type CustomLinkField = LinkField | LinkFieldValue;
type CustomLinkProps = {
field: CustomLinkField;
} & LinkProps;
const getLinkValue = (linkField: CustomLinkField): LinkFieldValue => {
return 'href' in linkField ? linkField : (linkField.value as LinkFieldValue);
};
/**
* Fixes an edge case where an internal URL with a querystring
* and/or hash is incorrectly set as an external link.
* This can happen when content editors paste internal URLs into
* external link fields in Sitecore.
*
* The function:
* 1. Checks if the link is marked as external but starts with
* '/' (indicating internal URL).
* 2. If a hash exists, separates it from the base URL.
* 3. If a querystring exists, separates it from the base URL.
* 4. Returns a fixed link object with the separated hash and
* querystring.
*
* @param field - The link field to check and potentially fix
* @returns The fixed link field value with separated hash and querystring, or original field if no fix needed
*/
const splitInternalUrlHashAndQuerystringInExternalLink = (
field: CustomLinkField
): LinkFieldValue => {
if (!field) return field;
const value = getLinkValue(field);
if (!value.href) return value;
const isInternalUrlInExternalLink = value.linktype === 'external' && value.href.startsWith('/');
if (!isInternalUrlInExternalLink) return value;
const indexOfHash = value.href.indexOf('#');
const hasHash = indexOfHash !== -1;
const indexOfQueryString = value.href.indexOf('?');
const hasQueryString = indexOfQueryString !== -1;
if (!hasHash && !hasQueryString) return value;
let href = value.href;
let anchor = '';
let querystring = '';
if (hasHash) {
anchor = href.substring(indexOfHash + 1);
href = href.substring(0, indexOfHash);
}
if (hasQueryString) {
querystring = href.substring(indexOfQueryString + 1);
href = href.substring(0, indexOfQueryString);
}
const fixedLink = {
...value,
href,
anchor,
querystring,
};
return fixedLink;
};
export const CustomLink = ({ field, ...props }: CustomLinkProps) => {
const { sitecoreContext } = useSitecoreContext();
const isEditMode = sitecoreContext?.pageState === LayoutServicePageState.Edit;
// Do not alter the field value in edit mode
if (isEditMode) {
return <Link field={field} {...props} />;
}
const updatedField = {
...field,
value: splitInternalUrlHashAndQuerystringInExternalLink(field),
};
return <Link field={updatedField} {...props} />;
};
This wrapper component can be used like this in your own components:
import { LinkField } from '@sitecore-jss/sitecore-jss-nextjs';
import { CustomLink } from './CustomLink';
type MyComponentProps = {
fields: {
link: LinkField;
};
};
export const MyComponent = (props: MyComponentProps) => (
<CustomLink field={props.fields.link} />
);
The wrapper component provides a robust solution that can handle existing content without requiring manual updates, while maintaining full compatibility with Sitecore's editing experience.
Conclusion
Every day we find creative ways content authors are using Sitecore. As developers, we must often adapt our code to support them. Being able to investigate issues step by step is a crucial skill for success. Having the Sitecore JSS SDK open source is a blessing that allows us to dig even deeper and develop creative solutions.
I hope this incursion into my thought process and this link wrapper component will be helpful for you.
Happy Sitecoring!