Posted on 06/07/2026 13:33:35
FYI: We have just last week made a POC of a new URL system for DW10 - and it contains a history supporting both 301s for renames and 410s for deletes. But it is very early and POC only and no promise it will make in production.
Instead of trying to record history in redirects table
Change to high volume URLs. You can rename products as much as you want and the URL continues to work and redirect to the new URL. Also it performs better and for many results in better SEO since the SKU is in the URL.
To implement history - my notes:
We do not have this feature because it will in no time add potentially million of records to the URL table. So part of your solution should be to clean that table up.
A good place to start is to fill the URL field on a product and a group so that it will not change URLs before the 'use in url' field is changed - deliberately. That would make it much more manageable as simple renames of products will not change the URL.
A good way to start is to take a look at what is in Dynamicweb.Frontend.UrlHandling.Indexes - it contains all the URLindexes and their urls and querystrings. So that is where you should find the data you want to store in the redirect table.
Some sample code:
internal static string WriteNodes()
{
var stringBuilder = new StringBuilder();
foreach (KeyValuePair<string, UrlIndex> index in UrlIndex.Indexes)
{
stringBuilder.AppendFormat(CultureInfo.InvariantCulture, "<h3>Index name: {0}</h3>", index.Key);
foreach (UrlNode rootnode in index.Value.RootNodes)
WriteNode(rootnode, 0, stringBuilder);
}
return stringBuilder.ToString();
}
internal static void WriteNode(UrlNode node, int depth, StringBuilder builder)
{
string indent = string.Empty;
for (int i = 0, loopTo = depth; i <= loopTo; i++)
indent += " ";
builder.AppendLine(indent + node.PathName + string.Format(CultureInfo.InvariantCulture, " ({0}, {1}, Count: {2})", node.GetPath(), node.GetQueryString(), node.NodeCount()) + "<br>");
foreach (UrlNode n in node.Children)
WriteNode(n, depth + 1, builder);
}
Below is how we reset the URL index - meaning when changes occur that affects the URLs. You can use the same concept and then figure out what to put in the redirect table. But just a warning that it is not easy and you will end up with data, collisions and other things you need to support and handle.
[Subscribe(Notifications.Ecommerce.Product.ProductGroupRelationAfterSave)]
[Subscribe(Notifications.Ecommerce.Product.ProductGroupRelationDeleted)]
public class ProductGroupRelationChangedObserver : NotificationSubscriber
{
public override void OnNotify(string notification, NotificationArgs args)
{
if (SystemConfiguration.Instance.GetBoolean("/Globalsettings/System/Url/HighVolumeProductUrls/Enable"))
return;
var needsReset = false;
if (args is Notifications.Ecommerce.Product.ProductGroupRelationAfterSaveArgs productGroupRelationAfterSaveArgs)
{
var newGrouprelation = Services.ProductGroups.GetGroup(productGroupRelationAfterSaveArgs.GroupRelation.GroupId, Services.Languages.GetDefaultLanguageId());
if (newGrouprelation is null || GroupService.IsGroupInEcommerceShop(newGrouprelation))
{
needsReset = true;
}
}
if (args is Notifications.Ecommerce.Product.ProductGroupRelationDeletedArgs productGroupRelationDeletedArgs)
{
var groupId = productGroupRelationDeletedArgs.GroupId;
var languageId = productGroupRelationDeletedArgs.Group?.LanguageId ?? Services.Languages.GetDefaultLanguageId();
var deletedGrouprelation = !string.IsNullOrEmpty(groupId)
? Services.ProductGroups.GetGroup(groupId, languageId)
: null;
if (deletedGrouprelation is null || GroupService.IsGroupInEcommerceShop(deletedGrouprelation))
{
needsReset = true;
}
}
if (needsReset)
{
lazyGroupProductRelationIndex = new Lazy<ConcurrentDictionary<string, List<string>>>(InitializeGroupProductRelationIndex);
UrlHelper.SetResetNeeded();
}
}
}
[Subscribe(Notifications.Ecommerce.Product.AfterSave)]
[Subscribe(Notifications.Ecommerce.Product.AfterDelete)]
public class ProductChangedObserver : NotificationSubscriber
{
public override void OnNotify(string notification, NotificationArgs args)
{
if (SystemConfiguration.Instance.GetBoolean("/Globalsettings/System/Url/HighVolumeProductUrls/Enable"))
return;
var changeAffectUrlIndex = false;
if (args is Dynamicweb.Ecommerce.Notifications.Ecommerce.Product.AfterSaveArgs afterSaveArgs)
{
var beforeSaveProduct = afterSaveArgs.ProductBeforeChanges;
var afterSaveProduct = afterSaveArgs.Product;
if (beforeSaveProduct is not null)
{
if (beforeSaveProduct.Groups.Count > 0 && !beforeSaveProduct.Groups.Any(group => GroupService.IsGroupInEcommerceShop(group) is true))
{
return; //The product is not member of any ecommerce groups
}
changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Id, afterSaveProduct.Id, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.VariantId, afterSaveProduct.VariantId, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.LanguageId, afterSaveProduct.LanguageId, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Name, afterSaveProduct.Name, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveProduct.Meta.Url, afterSaveProduct.Meta.Url, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= beforeSaveProduct.Active != afterSaveProduct.Active;
}
else
{
changeAffectUrlIndex = true;
}
}
if (changeAffectUrlIndex)
{
lazyProductUrlDataIndex = new Lazy<ConcurrentDictionary<string, Dictionary<string, UrlDataNode>>>(InitializeProductUrlDataIndex);
UrlHelper.SetResetNeeded();
}
}
}
[Subscribe(Notifications.Ecommerce.Group.AfterSave)]
[Subscribe(Notifications.Ecommerce.Group.Deleted)]
public class GroupChangedObserver : NotificationSubscriber
{
public override void OnNotify(string notification, NotificationArgs args)
{
var changeAffectUrlIndex = false;
if (args is Dynamicweb.Ecommerce.Notifications.Ecommerce.Group.AfterSaveArgs afterSaveArgs)
{
var beforeSaveGroup = afterSaveArgs.GroupBeforeChanges;
var afterSaveGroup = afterSaveArgs.Group;
if (beforeSaveGroup is not null)
{
if (GroupService.IsGroupInEcommerceShop(beforeSaveGroup))
{
changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Id, afterSaveGroup.Id, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.LanguageId, afterSaveGroup.LanguageId, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Name, afterSaveGroup.Name, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !string.Equals(beforeSaveGroup.Meta.Url, afterSaveGroup.Meta.Url, StringComparison.OrdinalIgnoreCase);
changeAffectUrlIndex |= !beforeSaveGroup.NavigationShowInSiteMap.Equals(afterSaveGroup.NavigationShowInSiteMap);
changeAffectUrlIndex |= !beforeSaveGroup.Meta.UrlIgnoreParent.Equals(afterSaveGroup.Meta.UrlIgnoreParent);
changeAffectUrlIndex |= !beforeSaveGroup.Meta.UrlIgnoreForChildren.Equals(afterSaveGroup.Meta.UrlIgnoreForChildren);
}
}
else
{
changeAffectUrlIndex = true;
}
}
if (changeAffectUrlIndex)
{
lazyProductUrlDataIndex = new Lazy<ConcurrentDictionary<string, Dictionary<string, UrlDataNode>>>(InitializeProductUrlDataIndex);
UrlHelper.SetResetNeeded();
}
}
}
[Subscribe(Notifications.Ecommerce.Group.RelationAdded)]
public class GroupRelationChangedObserver : NotificationSubscriber
{
public override void OnNotify(string notification, NotificationArgs args)
{
if (args is Notifications.Ecommerce.Group.GroupRelationModifiedEventArgs groupRelationModifiedEventArgs)
{
var newParent = Services.ProductGroups.GetGroup(groupRelationModifiedEventArgs.ParentId, Services.Languages.GetDefaultLanguageId());
var newChild = Services.ProductGroups.GetGroup(groupRelationModifiedEventArgs.ChildId, Services.Languages.GetDefaultLanguageId());
var needsReset = false;
if (newParent is not null && GroupService.IsGroupInEcommerceShop(newParent))
{
needsReset = true;
}
if (newChild is not null && GroupService.IsGroupInEcommerceShop(newChild))
{
needsReset = true;
}
if (needsReset)
UrlHelper.SetResetNeeded();
}
}
}