When we need access to specific values or data in our applications, we might inject them through configuration (especially if they are different for each environment), or store them in a database with an identifier and access them by this identifier in our code.
The first approach is often thought of as being architecturally sound 😎 because the application doesn't need to re-built (or even re-deployed) if the data changes. We only need to update the configuration, and maybe restart the app.
The second is considered bad 😒, because an accidental or incorrect change to the database means an application needs rebuilt and redeployed.
But, what if hard coding an identifier wasn't so risky and the identifier wasn't environment specific 😮? That sounds great, but we'll need a way of guaranteeing these things!
📚 What Will We Learn?
- When would we hard code identifiers in Xperience?
- How do we ensure they are stable between environments?
- How do we protect them from problematic modifications?
💎 Hard Coding Identifiers
Typically, when using a DXP, we will let content managers and marketers customize content for a site 😉, but sometimes it's more convenient to code content directly into an HTML template.
An example of this could be product pages on a site with thousands of products, each with a link to the Contact Us page (/contact-us
) at the bottom of the layout. This scenario isn't appropriate for the Page Builder and there's not much benefit to adding a custom Page Type field to a page in the content tree for the "Contact Us" link.
What's the best way to add these links to our templates 🤷♀️?
Generating Page Links
Hard-coding links in templates is a scenario where we want to reference an identifier in code, because we don't want to maintain a hard-coded URL over time.
Yes, Xperience's former URLs feature helps to ensure that moving pages around in the Content Tree or changing a URL slug will never break a URL 😅. However, there's several benefits to not hard-coding URLs:
- SEO: crawlers won't need to follow 301 redirects of moved pages
- UX: visitors won't need to follow 301 redirects and will have faster page loads
- Developers: it will be easier to maintain a site when links aren't hard-coded
- An innocent looking
/special-product
link could redirect to something completely unexpected 😑!
- An innocent looking
- Content Management: Multi-lingual sites will have a different URL per culture, which means a hard-coded path won't work 😞.
Ok, we've decided we do want to sometimes hard-code links, but we don't want to hard-code URLs. So, instead, we'll use an identifier of the page we are linking to.
In Xperience we have a couple of options to pick from:
NodeID
DocumentID
DocumentGUID
NodeGUID
Which should we choose 🧐?
🏛 Keeping Identifiers Stable Between Environments
If we want our URLs to be correctly generated on a multi-lingual site, we'll want to use one of the Node values, since the Document ones reference a single specific culture for a page - it wouldn't be a very friendly site if we always generated URLs to the Spanish site for all the other cultures 😁.
Xperience's SQL Server database will auto-increment page integer IDs, which means creating a page in one environment could associate it with a NodeID
that is being used by another page in a different environment.
If we have "Local", "Test", and "Production" environments, we don't want our code to fail to generate URLs 😬 (or generate incorrect URLs) because the identifier we reference isn't correct for that environment.
When using content staging Xperience guarantees pages synced from one environment to another will keep their same GUID
values (NodeGUID
and DocumentGUID
) while their ID
values can change.
So, given the option of NodeID
and NodeGUID
, we are going to select NodeGUID
because it's stable between environments 🙌🏾!
🏗 Generating URLs
Now that we've decided on our stable page identifier, how are we going to generate URLs for those pages?
We could use Page Type Guid
fields combined with the Page Selector Form Control, and then retrieve the URL in code:
Guid nodeGUID = productPage.Fields.CTALinkPageNodeGUID;
TreeNode? linkedPage = pageRetrieve
.Retrieve<TreeNode>(
query => query
.WhereEquals(nameof(TreeNode.NodeGUID), nodeGUID),
cache => cache.Key($"CTA|{nodeGUID}"))
.FirstOrDefault();
if (linkedPage is null)
{
return null;
}
PageUrl linkedPageUrl = pageUrlRetriever.Retrieve(linkedPage);
return ProductViewModel(productPage, linkedPageUrl);
This isn't too complicated, but the whole idea here was to generate the URLs without content management customization, so a Page Type field doesn't align with our goal 🙁.
Page Link Tag Helper
Instead, I'm recommending we use an ASP.NET Core Tag Helper that will enhance <a>
elements in our Views:
<a href="" xp-page-link="..."></a>
This Tag Helper will use one of our identifiers to perform a very similar series of data retrieval steps to what we outlined above, and then populate the href
and link text of our <a>
.
But what do we pass to the xp-page-link
attribute? A hardcoded Guid
, while technically correct, is going to be very difficult to understand when reading the .cshtml
file:
- Which page does it represent 😵?
- Did we accidentally typo it somewhere 😖?
- What if we need to delete and then recreate the page we want to link to and have to replace the
Guid
everywhere ☠?
LinkablePage
Instead, let's create a class with friendly names to encapsulate our identifiers:
public class LinkablePage
{
public static LinkablePage Home { get; } =
new LinkablePage(new Guid("..."));
public static LinkablePage ContactUs { get; } =
new LinkablePage(new Guid("..."));
public static LinkablePage PrivacyPolicy { get; } =
new LinkablePage(new Guid("..."));
public Guid NodeGUID { get; }
protected LinkablePage(Guid nodeGUID) => NodeGUID = nodeGUID;
public static All LinkablePage[] { get; } = new[]
{
Home,
ContactUs,
PrivacyPolicy
};
}
This LinkablePage
class contains the full set of any pages we might want to generate URLs for, stores the NodeGUID
values of each of those pages, and makes them immutable. The constructor is also protected
, so no new objects can be created elsewhere in the application.
This last point is important because these values need to be known at development time, not runtime.
Here's how we'd use one with our Tag Helper:
<a href="" xp-page-link="LinkablePage.PrivacyPolicy"></a>
Because these identifiers are static
properties, we could get fancy with our Razor and add a @using static
at the top of the Razor file to access the LinkablePage
instances directly:
@using static Sandbox.Data.LinkablePage
<a href="" xp-page-link="PrivacyPolicy"></a>
These are both readable, and easy to author 👏🏼.
Assuming the Tag Helper caches the URLs of links it generates for each of the different pages, it's performant as well 💪🏽!
The implementation for this tag helper can be found in the NuGet package for Xperience Page Link Tag Helpers. Try it out!
👮🏽♀️ Protecting Identifiers
Now that we've leveraged this convenient and maintainable approach for generating links to pages in the content tree, how do we guarantee that these don't suddenly stop working because pages get deleted?
Kentico Xperience has the perfect tool for us to leverage to guarantee the consistency of our data while the application is running - Global Events.
We can register an event handler for the DocumentEvents.Delete.Before
event and then cancel the event if the Page being deleted matches a LinkablePage
:
public class LinkablePageProtectionModule : Module
{
public LinkablePageProtectionModule
: base(nameof(LinkablePageProtectionModule));
protected override void OnInit()
{
base.OnInit();
DocumentEvents.Delete.Before += Delete_Before;
}
private void Delete_Before(object sender, DocumentEventArgs e)
{
if (LinkablePage.All.Any(
p => p.NodeGUID == e.Node.NodeGUID))
{
e.Cancel();
var log = Service.Resolve<IEventLogService>();
string message = $"Cannot delete Linkable Page [{e.Node.NodeAliasPath}], as it might be in use. Please first remove the Linkable Page in the application code and re-deploy the application.";
log.LogError(
nameof(LinkablePageEventHandler),
"DELETE_PAGE",
message);
return;
}
}
}
This class's Delete_Before
method will be called each time a page is deleted and it will check to make sure that page doesn't have a NodeGUID
that matches one of the values we've hard coded in the application. If it does match, the deletion is canceled and we log a helpful message explaining why 😉.
The final step is to register our custom module in our application (preferably somewhere in our CMSApp
project, like a DependencyRegistrations.cs
file):
[assembly: RegisterModule(typeof(LinkablePageProtectionModule))]
This pattern for protecting hard coded identifiers can be used for anything in Xperience that supports content staging:
- E-Commerce data (payment methods, shipping options)
- Pages
- Custom Module class records
- Users/roles
- Categories
All data our application code directly depends on through hard-coded identifiers is probably worth protecting with this approach 🤓.
🏁 Conclusion
Usually we want to leave content management up to marketers and content managers (that's the whole point of a DXP!) 👍🏿.
But sometimes it's far more convenient to manage some of the content ourselves - especially for links to pages that don't need to change frequently (or ever).
However, we still want to make sure that these links are correct and don't break between environments or while the site is running.
By storing a page's NodeGUID
value in the application code, and using both content staging and a custom module to prevent accidental page deletions, we can achieve all of our goals 🤗.
We've created a readable, maintainable, scalable, performant, environment-consistent, and robust way of generating links to pages in our application 🥳.
As always, thanks for reading 🙏!
References
- Kentico Xperience - DXP
- Kentico Xperience Docs - Page Builder Development
- Kentico Xperience Docs - Former URLs
- Kentico Xperience Docs - Content Staging
- ASP.NET Core - Tag Helpers
- Xperience Page Link Tag Helpers
- Kentico Xperience Docs - Global Events
We've put together a list over on Kentico's GitHub account of developer resources. Go check it out!
If you are looking for additional Kentico content, checkout the Kentico or Xperience tags here on DEV.
Or my Kentico Xperience blog series, like:
Top comments (0)