Blog
Creating Redirects with Prismic and NextJs

Creating Redirects with Prismic and NextJs

If you've done web development for any length of time, you'll know an important part of SEO is the ability to set up 301 Redirects on a page. The reason is simple, while pages exist people link to them, and they generate SEO value. When they're removed, the SEO value is removed, and even worse there could now be dead links existing pointing at that now removed page. The solution is to set up a redirect to that page that replaces it resulting in no dead links and the SEO value being transferred to the new page.

However, despite this being an almost essential part of any website, countless CMSs don't come with any inbuilt functionality to set them up and most guides will point you to config files requiring a developer to make the change. Not only does the average developer not want to spend their time adding redirects to config files, but content editors also don't want to have to wait for them to do it either.

Create a Document Type in Prismic

To give content editors the power to create redirects themselves, first, they will need a document type in Prismic with the relevant fields. The custom type needs to be repeatable so that the editor can enter multiple redirects, and it will need fields for source, destination and if the redirect is temporary or permanent.

Prismic custom type redirect

For the redirect type, I've used a select drop-down with the default set to Permanent.

Using the redirects in NextJs

Since version 9.5.0 of NextJs, you have been able to set redirects in the next.config.js file (official NextJs documentation). What might not be immediately obvious though is the config file is a regular JS file so although all the examples are static hard-coded redirects there's no reason you couldn't just call a function that returns an array.

If like me, you're using TypeScript throughout your projects this is where the good news ends. The config file cannot be a TypeScript file and therefore if you've made a nice service layer with all your queries to the Prismic client, your logic for redirects, unfortunately, can't go there.

To create your client to retrieve content from Prismic, you likely import the createClient function from the prismicio.js file. Two things will trip you up here, the first will be an error about using import statements outside a module.

1(node:21168) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
2(Use `node --trace-warnings ...` to show where the warning was created)
3error - Failed to load next.config.js, see more info here https://nextjs.org/docs/messages/next-config-error
4C:\GitHub\TeamSport2022\next.config.js:1
5import * as prismic from '@prismicio/client'
6^^^^^^
7
8SyntaxError: Cannot use import statement outside a module
9 at Object.compileFunction (node:vm:352:18)
10 at wrapSafe (node:internal/modules/cjs/loader:1032:15)
11 at Module._compile (node:internal/modules/cjs/loader:1067:27)
12 at Object.Module._extensions..js (node:internal/modules/cjs/loader:1157:10)
13 at Module.load (node:internal/modules/cjs/loader:981:32)
14 at Function.Module._load (node:internal/modules/cjs/loader:822:12)
15 at ModuleWrap.<anonymous> (node:internal/modules/esm/translators:168:29)
16 at ModuleJob.run (node:internal/modules/esm/module_job:197:25)
17 at async Promise.all (index 0)
18 at async ESMLoader.import (node:internal/modules/esm/loader:337:24)

Thankfully next's config file does support ECMAScript modules and you can rename the file to next.cofig.msj. Just make sure you follow all the other changes in Next's documentation.

The next will be an error message that looks helpful with a suggested change.

1import { createClient } from './prismicio.js'
2 ^^^^^^^^^^^^
3SyntaxError: Named export 'createClient' not found. The requested module './prismicio.js' is a CommonJS module, which may not support all module.exports as named exports.
4CommonJS modules can always be imported via the default export, for example using:
5
6import pkg from './prismicio.js';
7const { createClient } = pkg;
8
9 at ModuleJob._instantiate (node:internal/modules/esm/module_job:127:21)
10 at async ModuleJob.run (node:internal/modules/esm/module_job:193:5)
11 at async Promise.all (index 0)
12 at async ESMLoader.import (node:internal/modules/esm/loader:337:24)
13 at async importModuleDynamicallyWrapper (node:internal/vm/module:437:15)
14 at async Object.loadConfig [as default] (C:\GitHub\TeamSport2022\node_modules\next\dist\server\config.js:68:36)
15 at async NextServer.prepare (C:\GitHub\TeamSport2022\node_modules\next\dist\server\next.js:127:24)
16 at async C:\GitHub\TeamSport2022\node_modules\next\dist\cli\next-dev.js:142:9

But that will just result in the first error message again, but now for the prismicio file.

The solution I ended up with was to recreate some of the logic from the prismicio file to create the client. Thankfully none of the routing logic needs to be replicated so it's still fairly minimal code, the one downside is the API endpoint now needs to come from the .env file rather than Prismics sm.json file. I did try using the sm.json file, however that leads to another error around loading json files.

1import * as prismic from '@prismicio/client'
2
3const apiEndpoint = process.env.PRISMIC_API_ENDPOINT
4export const repositoryName = prismic.getRepositoryName(apiEndpoint)
5
6export const createClient = (config = {}) => {
7 const client = prismic.createClient(apiEndpoint, {
8 undefined,
9 ...config,
10 })
11
12 return client
13}
14
15async function redirects() {
16 const client = createClient()
17 const redirects = await client.getAllByType('redirect')
18
19 return redirects.map((r) => {
20 return {
21 source: r.data.from_url,
22 destination: r.data.to_url,
23 permanent: r.data.redirect_type == 'Permanent',
24 }
25 })
26}
27
28/**
29 * @type {import('next').NextConfig}
30 */
31const nextConfig = {
32 /* config options here */
33 redirects,
34}
35
36export default nextConfig
37

As you can see once the client is created, it's a simple case of retrieving the redirects and mapping them into the correct format.

Overall, I'm happy that the redirects can be added through Prismic and they use the official NextJs config for creating redirects. However, I'm not so keen that my solution now has two places where the API endpoint is defined and the query for loading redirects being in what is essentially a config file, rather than with all my others and benefitting from the type checking it would have received had it been in typescript.

Tagged: 
Array parameter in WebAPI Get Request

Array parameter in WebAPI Get Request

REST APIs are great, and ASP.NET Web API is a nice way of creating one. The auto-generated code that Visual Studio gives you will also get you 90% of the way there to knowing how to build one without reading much documentation.

However, while it will give you all the GET, POST, PUT, DELETE etc endpoints there's one thing it doesn't give you an example for, and that's query-string parameters on your GET request.

In the real world, the likelihood of you wanting the GET route to return everything is quite slim. You may have small datasets where this is true but more often than not, you're going to want some sort of query string values to use as a filter.

Fortunately, ASP.NET makes this simple with parameter binding. Just add them as parameters to your function.

1// GET: api/<People>
2[HttpGet]
3public IEnumerable<Person> Get(int? age, string? gender)
4{
5 ...
6}

Make sure to make them nullable if you don't want them as required fields.

Swagger will even pick up on them and include them as parameter options.

Swagger People API

It's also likely that you'll want an array of values so that in my example you could select multiple ages or genders. However, if you just try turning them into arrays you will end up with this build error.

1'API.Controllers.PeopleController.Get (API)' has more than one parameter that was specified or inferred as bound from request body. Only one parameter per action may be bound from body. Inspect the following parameters, and use 'FromQueryAttribute' to specify bound from query, 'FromRouteAttribute' to specify bound from route, and 'FromBodyAttribute' for parameters to be bound from

If we only had one array things would be ok as the error is for specifying multiple. Except if we look at swagger things still aren't quite right.

Swagger Get request with body

The age parameter is now showing as coming from the body rather than the query-string. It also explains why you could only specify one array as a parameter.

The solution is to specify that these parameters should come from the query-string which we can do by using the [FromQuery] attribute.

1// GET: api/<PeopleController>
2[HttpGet]
3public IEnumerable<Person> Get([FromQuery] int[]? age, [FromQuery] string[]? gender)
4{
5 ...
6}

The solution will now run even with multiple array parameters, and swagger will also pick up that these are query-string array parameters and give you the option to add individual items when you test a request.

Swagger with Arrays
How to create a Graph QL API on Azure Functions

How to create a Graph QL API on Azure Functions

REST APIs are great, but they can result in either your application making an excessive number of requests to multiple endpoints, then only using a small percentage of the data returned. Or you end up making a large number of endpoints for specific purposes and now have maintenance hell to deal with.

If that's your situation then one option is to look at replacing some of that functionality with a Graph QL API. I'm not going to dig into what Graph QL APIs are (that's been covered by many people before me), but what I will do is show you how to make one in an Azure Function.

Your starting point is to use Hot Chocolate by Chilli Cream, not only does it have a fun meaningless name, but it also offers some great simple-to-use functionality. However, despite stating it works with Azure Functions, the documentation is all for ASP.NET Core, which is not the same thing.

Another issue I have with the documentation is that it doesn't explain particularly well how you configure it to work with a data access layer. Examples either have methods that return a dataset containing all related data, or they use Entity Framework, which as you generally wouldn't use your DB schema as an API schema feels like cheating.

So here is my guide from file new project to a working Graph QL API in an Azure Function.

File New Project

Starting right at the beginning, open Visual Studio and create a new Azure Function. For this demo, I'm using .NET 6 as that's the latest at the time of writing, and am going to create an HTTP trigger.

Create new Azure Function screen

For a data source, I've created a hard-coded repository containing Schools, Classes, and Students. Schools contain multiple classes and classes contain multiple students. Each repository contains functions to get all, get by id or get by the thing it's related to. e.g. Get Students by Class. Here's my code for it.

1using AzureFunctionWithGraphApi.Models;
2using System.Collections.Generic;
3using System.Linq;
4
5namespace AzureFunctionWithGraphApi.DataAccess
6{
7 public interface ISchoolRepository
8 {
9 List<School> All();
10 School GetById(int id);
11 }
12
13 public interface IClassRepository
14 {
15 List<Class> All();
16 Class GetById(int id);
17 List<Class> GetBySchool(int schoolId);
18 }
19
20 public interface IStudentRepository {
21 List<Student> All();
22 Student GetById(int id);
23 List<Student> GetByClass(int classId);
24 }
25
26 public static class DemoData
27 {
28 public static List<School> Schools = new List<School>()
29 {
30 new School() {Id = 1, Name = "Foo School"},
31 new School() {Id = 2 , Name = "Boo School"},
32 };
33
34 public static List<Class> ClassList = new List<Class>()
35 {
36 new Class() {Id = 3, SchoolId = 1, Name = "Red Class", YearGroup = 1},
37 new Class() {Id = 4, SchoolId = 1, Name = "Blue Class", YearGroup = 2},
38 new Class() {Id =5, SchoolId = 2, Name = "Yellow Class", YearGroup = 1},
39 new Class(){Id = 6, SchoolId = 2, Name = "Green Class", YearGroup = 2}
40 };
41
42 public static List<Student> Students = new List<Student>()
43 {
44 new Student() {Id = 1, ClassId = 3, FirstName = "John", Surname = "Smith"},
45 new Student() {Id = 2, ClassId = 3, FirstName = "Sam", Surname = "Smith"},
46 new Student() {Id = 3, ClassId = 4, FirstName = "Eric", Surname = "Smith"},
47 new Student() {Id = 4, ClassId = 4, FirstName = "Rachel", Surname = "Smith"},
48 new Student() {Id = 5, ClassId = 5, FirstName = "Tom", Surname = "Smith"},
49 new Student() {Id = 6, ClassId = 5, FirstName = "Sally", Surname = "Smith"},
50 new Student() {Id = 7, ClassId = 6, FirstName = "Sharon", Surname = "Smith"},
51 new Student() {Id = 8, ClassId = 6, FirstName = "Kate", Surname = "Smith"}
52 };
53 }
54
55 public class SchoolRepository : ISchoolRepository
56 {
57 public List<School> All()
58 {
59 return DemoData.Schools;
60 }
61
62 public School GetById(int id)
63 {
64 return DemoData.Schools.Where(x => x.Id == id).FirstOrDefault();
65 }
66 }
67
68 public class ClassRepository : IClassRepository
69 {
70 public List<Class> All()
71 {
72 return DemoData.ClassList;
73 }
74
75 public Class GetById(int id)
76 {
77 return DemoData.ClassList.Where(x => x.Id == id).FirstOrDefault();
78 }
79
80 public List<Class> GetBySchool(int schoolId)
81 {
82 return DemoData.ClassList.Where((x) => x.SchoolId == schoolId).ToList();
83 }
84 }
85
86 public class StudentRepository : IStudentRepository
87 {
88 public List<Student> All()
89 {
90 return DemoData.Students;
91 }
92
93 public List<Student> GetByClass(int classId)
94 {
95 return DemoData.Students.Where((x) => x.ClassId == classId).ToList();
96 }
97
98 public Student GetById(int id)
99 {
100 return DemoData.Students.Where(x => x.Id == id).FirstOrDefault();
101 }
102 }
103}
104

If you want to use it, you'll also need the related models.

1public class School
2 {
3 public int Id { get; set; }
4 public string Name { get; set; }
5 }
6
7public class Class
8 {
9 public int Id { get; set; }
10 public int SchoolId { get; set; }
11 public int YearGroup { get; set; }
12 public string Name { get; set; }
13 }
14
15public class Student
16 {
17 public int Id { get; set; }
18 public int ClassId { get; set; }
19 public string FirstName { get; set; }
20 public string Surname { get; set; }
21 }

Create a Graph QL API

With our project and data access layer created, lets get on with how to create a Graph QL in a .NET Azure Function.

Hot chocolate will provide all the functionality and can be added to your solution via Nuget. Just search for Hot Chocolate and make sure you pick the Azure Function version.

Hot Chocolate NuGet package

The HTTP Endpoint we created when creating the function needs updating to provide the route for the graph API.

1using System.Threading.Tasks;
2using Microsoft.AspNetCore.Mvc;
3using Microsoft.Azure.WebJobs;
4using Microsoft.Azure.WebJobs.Extensions.Http;
5using Microsoft.AspNetCore.Http;
6using Microsoft.Extensions.Logging;
7using HotChocolate.AzureFunctions;
8
9namespace AzureFunctionWithGraphApi
10{
11 public class GraphQlApi
12 {
13 [FunctionName("HttpExample")]
14 public async Task<IActionResult> Run(
15 [HttpTrigger(AuthorizationLevel.Anonymous, "get", "post", Route = "graphql/{**slug}")] HttpRequest req,
16 [GraphQL] IGraphQLRequestExecutor executor,
17 ILogger log)
18 {
19 log.LogInformation("C# HTTP trigger function processed a request.");
20
21 return await executor.ExecuteAsync(req);
22 }
23 }
24}
25

Next we need to configure what queries can be performed on the graph. For my example, I'm replicating the Get All and Get By Id methods from my data access layer.

One thing to note here is although I'm using dependency injection for my repositories they are using resolver injection on the methods rather than constructor injection. You can read more about why this is on the Chilli Cream site here, but essentially constructor injector won't work.

1using AzureFunctionWithGraphApi.DataAccess;
2using AzureFunctionWithGraphApi.Models;
3using HotChocolate;
4using System.Collections.Generic;
5
6namespace AzureFunctionWithGraphApi
7{
8 public class Query
9 {
10 public List<School> GetSchools([Service] ISchoolRepository schoolRepository)
11 {
12 return schoolRepository.All();
13 }
14 public School GetSchoolById([Service] ISchoolRepository schoolRepository, int schoolId)
15 {
16 return schoolRepository.GetById(schoolId);
17 }
18
19 public List<Class> GetClasses([Service] IClassRepository classRepository)
20 {
21 return classRepository.All();
22 }
23 public Class GetClassById([Service] IClassRepository classRepository, int classId)
24 {
25 return classRepository.GetById(classId);
26 }
27
28 public List<Class> GetClassesBySchoolId([Service] IClassRepository classRepository, int schoolId)
29 {
30 return classRepository.GetBySchool(schoolId);
31 }
32
33 public List<Student> GetStudents([Service] IStudentRepository studentRepository)
34 {
35 return studentRepository.All();
36 }
37 public Student GetStudentById([Service] IStudentRepository studentRepository, int studentId)
38 {
39 return studentRepository.GetById(studentId);
40 }
41
42 public List<Student> GetStudentsBySchoolId([Service] IStudentRepository studentRepository, int classId)
43 {
44 return studentRepository.GetByClass(classId);
45 }
46 }
47}
48

At this point (apart from the fact we haven't configured the startup file with our DI) you will now have a Graph QL API but it won't be able to load any related items. You will however be able to pick which fields you want from the datasets.

To add the related data we need to create extension methods for our models. These inject the instance of the item using Hot Chocolates Parent attribute, and the repository we're going to use to get the data.

1using AzureFunctionWithGraphApi.DataAccess;
2using AzureFunctionWithGraphApi.Models;
3using HotChocolate;
4using HotChocolate.Types;
5using System.Collections.Generic;
6
7namespace AzureFunctionWithGraphApi
8{
9 [ExtendObjectType(typeof(School))]
10 public class SchoolExtensions
11 {
12 public List<Class> GetClasses([Parent] School school, [Service] IClassRepository classRepository)
13 {
14 return classRepository.GetBySchool(school.Id);
15 }
16 }
17
18 [ExtendObjectType(typeof(Class))]
19 public class ClassExtensions
20 {
21 public School GetSchool([Parent] Class schoolClass, [Service] ISchoolRepository schoolRepository)
22 {
23 return schoolRepository.GetById(schoolClass.SchoolId);
24 }
25 public List<Student> GetStudents([Parent] Class schoolClass, [Service] IStudentRepository studentRepository)
26 {
27 return studentRepository.GetByClass(schoolClass.Id);
28 }
29 }
30
31 [ExtendObjectType(typeof(Student))]
32 public class StudentExtensions
33 {
34 public Class GetClass([Parent] Student student, [Service] IClassRepository classRepository)
35 {
36 return classRepository.GetById(student.ClassId);
37 }
38 }
39}
40

Now all that's left is to configure our startup file. This file no longer gets created when you create the Azure Function so you'll need to add it yourself.

Here's mine. As you can see I'm registering the dependency injection for my repositories, and also configuring the GraphQL. This needs to include the query class we made and any extension classes.

1using AzureFunctionWithGraphApi.DataAccess;
2using Microsoft.Azure.Functions.Extensions.DependencyInjection;
3using Microsoft.Extensions.DependencyInjection;
4
5[assembly: FunctionsStartup(typeof(AzureFunctionWithGraphApi.Startup))]
6namespace AzureFunctionWithGraphApi
7{
8 public class Startup : FunctionsStartup
9 {
10 public override void Configure(IFunctionsHostBuilder builder)
11 {
12 builder.Services.AddScoped<ISchoolRepository, SchoolRepository>();
13 builder.Services.AddScoped<IClassRepository, ClassRepository>();
14 builder.Services.AddScoped<IStudentRepository, StudentRepository>();
15
16 builder.AddGraphQLFunction()
17 .AddQueryType<Query>()
18 .AddTypeExtension<SchoolExtensions>()
19 .AddTypeExtension<ClassExtensions>()
20 .AddTypeExtension<StudentExtensions>();
21 }
22 }
23}
24

Run the Application and navigate in a browser to it's one route and you should get see the Banana Cake Pop UI to be able to view your schema.

Banana Cake Pop UI showing Scheme Reference

You can also test out queries selecting just the data you want even on related items.

Banana Cake Pop UI showing Query

We could even start with selecting a specific student and pull in their related class and school info.

Banana Cake Pop Graph QL Query

The Bad News

All of this is great and in fact, even more, functionality is available to be added, but there is some bad news. Not all of Hot Chocolates functionality actually works in an Azure Function, specifically authentication.

You can read about Hot Chocolates implementation of Authentication and Authorization here however it uses ASP.NET Core authentication middleware and Authorize attributes which do not work in Azure Functions. So unless you want your Graph QL API to be fully public you may be out of luck with this being a solution.

Code for this Demo

Now for the good news, if you want to try this without typing all the code, you can get a copy of it from my GitHub here.

https://github.com/timgriff84/AzureFunctionWithGraphApi

Integrating Sitecore with Algolia

Integrating Sitecore with Algolia

Out of the box Sitecore ships with Solr as its search provider. As a Sitecore developer the amount of Solr knowledge you need is relatively low, as you access it through Sitecore's own APIs. This makes things simple to get going as it doesn't require a huge amount of effort. However this is where all that's good about using Solr seems to end.

It's not that Solr is bad, it's actually very powerful, has a load of config options for boosting fields, result items etc. If there's something you want to do with your search results then it can probably do it. For admin users though it's just a bit of a black box. Results come out in an order and sometimes your not sure why. If you asked a content editor to change the order of search results they would look at you blankly and not have a clue where to start, other than to ask their dev to do it for them.

Algolia on the other hand, has been designed for the end user. They can try searches through the admin interface, drag and drop results into a different order, run campaigns and affect results in numerous other ways. Not only that but it offers analytics so they can see what searches are returning no results along with searches that have results, but no click throughs.

For devs it's also easy to see what's actually in the search index and front end devs can easily integrate through the APIs rather than requiring a .NET dev to write something against Sitecore's search provider for them.

Creating a search with Algolia and Sitecore

In this article I'm going to show you how to populate an Algolia index with data from Sitecore. What I'm not doing is creating a new Sitecore search provider for Algolia. Other people have attempted that before, but it requires a lot of maintenance. You also have to implement a lot of functionality that your unlikely to use!

My aim also isn't to replace Solr. Sitecore uses it for some of it's functionality and is doing that job perfectly fine. My aim is to add a search to the front end of a Sitecore site powered by Algolia so that a content editor can make use of Algolias features. For that I just need relevant content from Sitecore to be added and removed from Algolias index when a publish happens.

Populating Algolias Index from Sitecore

We want our Algolia Index to contain data for published items and to update as future publishes occur. Publishing being when content is published from the Master DB to the Web DB. A good way to do this is to hook into the Sitecore publishing pipeline.

In my solution I am creating a new pipeline processor that calls directly to Algolia. In my case the amount of content is relatively small and for usability when the publish dialogue completes I want the content editors to be confident that the index has updated. A more scalable solution that is less blocking would be to first post the data to a service bus and then have the integration to Algolia subscribe to the bus. This way any delay caused by Algolia wont affect the publishing experience.

The default PublishItem pipeline in Sitecore is as follows:

1<publishItem help="Processors should derive from Sitecore.Publishing.Pipelines.PublishItem.PublishItemProcessor">
2<processor type="Sitecore.Publishing.Pipelines.PublishItem.RaiseProcessingEvent, Sitecore.Kernel"/>
3<processor type="Sitecore.Publishing.Pipelines.PublishItem.CheckVirtualItem, Sitecore.Kernel"/>
4<processor type="Sitecore.Publishing.Pipelines.PublishItem.CheckSecurity, Sitecore.Kernel"/>
5<processor type="Sitecore.Publishing.Pipelines.PublishItem.DetermineAction, Sitecore.Kernel"/>
6<processor type="Sitecore.Buckets.Pipelines.PublishItem.ProcessActionForBucketStructure, Sitecore.Buckets" patch:source="Sitecore.Buckets.config"/>
7<processor type="Sitecore.Publishing.Pipelines.PublishItem.MoveItems, Sitecore.Kernel"/>
8<processor type="Sitecore.Publishing.Pipelines.PublishItem.PerformAction, Sitecore.Kernel"/>
9<processor type="Sitecore.Publishing.Pipelines.PublishItem.AddItemReferences, Sitecore.Kernel"/>
10<processor type="Sitecore.Publishing.Pipelines.PublishItem.RemoveUnknownChildren, Sitecore.Kernel"/>
11<processor type="Sitecore.Publishing.Pipelines.PublishItem.RaiseProcessedEvent, Sitecore.Kernel" runIfAborted="true"/>
12<processor type="Sitecore.Publishing.Pipelines.PublishItem.UpdateStatistics, Sitecore.Kernel" runIfAborted="true">
13<traceToLog>false</traceToLog>
14</processor>
15</publishItem>

The Sitecore.Publishing.Pipelines.PublishItem.PerformAction step in the pipeline is the one which does the actual work of updating the web db.

To capture deletes as well as inserts / updates we need a step to happen both before and after this action. The step before will capture the deletes and the step after will push the update to Algolia.

My code for capturing the deletes is as follows. This is needed as once the PerformAction step has finished, the item no longer exists so we need to grab it first.

1using Sitecore.Diagnostics;
2using Sitecore.Publishing;
3using Sitecore.Publishing.Pipelines.PublishItem;
4
5namespace SitecoreAlgolia
6{
7 public class DelateAlgoliaItemsAction : PublishItemProcessor
8 {
9 public override void Process(PublishItemContext context)
10 {
11 Assert.ArgumentNotNull(context, "context");
12
13 // We just want to process deletes because this is the only time the item being deleted may exist.
14 if (context.Action != PublishAction.DeleteTargetItem && context.Action != PublishAction.PublishSharedFields)
15 return;
16
17 // Attempt to find the item. If not found, item has already been deleted. This can occur when more than one langauge is published. The first language will delete the item.
18 var item = context.PublishHelper.GetTargetItem(context.ItemId) ??
19 context.PublishHelper.GetSourceItem(context.ItemId);
20
21 if (item == null)
22 return;
23
24 // Hold onto the item for the PublishChangesToAlgoliaAction PublishItemProcessor.
25 context.CustomData.Add("Item", item);
26 }
27 }
28}
29

With the deletes captured the next pipeline action will push each change to Algolia.

The first part of my function is going to ignore anything we're not interested in. This includes:

  • Publishes where the result of the operation was skipped or none (as nothings changed)
  • If we don't have an item
  • If the template of the item isn't one we're interested in pushing to Algolia
  • If the item is a standard values
1// Skip if the publish operation was skipped or none.
2if ((context.Action != PublishAction.DeleteTargetItem || context.PublishOptions.CompareRevisions) && (context.Result.Operation == PublishOperation.Skipped || context.Result.Operation == PublishOperation.None))
3 return;
4
5// For deletes the VersionToPublish is the parent, we need to get the item from the previous step
6var item = (Item)context.CustomData["Item"] ?? context.VersionToPublish;
7 if (item == null)
8 return;
9
10// Restrict items to certain templates
11var template = TemplateManager.GetTemplate(item);
12// SearchableTemplates is a List<ID>
13if (!SearchableTemplates.Any(x => template.ID == x))
14 return;
15
16// Don't publish messages for standard values
17if (item.ParentID == item.TemplateID)
18 return;

Next I convert the Sitecore items into a simple poco object. This is what the Algolia client requires for updating the index.

Notice the first property is called ObjectID, this is a required property for Algolia and is used to identify the record for updates and deletes. I'm using the Sitecore Item ID for this.

1// Convert item to the model for Algolia
2var searchItem = new SearchResultsItem()
3{
4 ObjectID = item.ID.ToString(),
5 Title = item.Fields[FieldNames.Standard.MenuTitle].Value,
6 Content = item.Fields[FieldNames.Base.Content].Value,
7 Description = item.Fields[FieldNames.Standard.ShortDescription].Value,
8};

One thing to note that I've not included here is to be careful with any link fields. If you are wanting to add a URL into Algolia you may find that the site context the publishing pipeline runs in may not be the same as your final website and therefore you need to set some additional URLOptions on the LinkManager to get the correct URLs.

Finally to push to Algolia it's a case of creating the SearchClient, initializing the index and picking the relevant operation on the index. Just make sure you install the Algolia.Search NuGet package.

1// Init Algolia Client
2SearchClient client = new SearchClient("<Application ID>", "<API Key>");
3SearchIndex index = client.InitIndex("<Index Name>");
4
5// Decide what type of update is going to Algolia
6var operation = (context.Action == PublishAction.DeleteTargetItem && !context.PublishOptions.CompareRevisions) ? PublishOperation.Deleted : context.Result.Operation;
7switch (operation)
8{
9 case PublishOperation.Deleted:
10 // Delete
11 index.DeleteObject(item.ID.ToString());
12 break;
13 case PublishOperation.Skipped:
14 // Skipped
15 break;
16 default:
17 // Created / Update
18 index.SaveObject(searchItem);
19 break;
20}

My complete class looks like this. For simplicity of the article I've built this quite crudely with everything in one giant function. For production you would want to split up as per good coding standards.

1using Algolia.Search.Clients;
2using SitecoreAlgolia.Models;
3using Sitecore.Data;
4using Sitecore.Data.Items;
5using Sitecore.Data.Managers;
6using Sitecore.Diagnostics;
7using Sitecore.Links;
8using Sitecore.Publishing;
9using Sitecore.Publishing.Pipelines.PublishItem;
10using System.Collections.Generic;
11using System.Linq;
12
13namespace SitecoreAlgolia
14{
15 public class PublishChangesToAlgoliaAction : PublishItemProcessor
16 {
17 private static readonly List<ID> SearchableTemplates = new[] {
18 ItemIds.Templates.PageTemplates.EventItem,
19 ItemIds.Templates.PageTemplates.Content,
20 }.Select(x => new ID(x))
21 .ToList();
22
23 public override void Process(PublishItemContext context)
24 {
25 Assert.ArgumentNotNull(context, "context");
26
27 // Skip if the publish operation was skipped or none.
28 if ((context.Action != PublishAction.DeleteTargetItem || context.PublishOptions.CompareRevisions) &&
29 (context.Result.Operation == PublishOperation.Skipped ||
30 context.Result.Operation == PublishOperation.None))
31 return;
32
33 // For deletes the VersionToPublish is the parent, we need to get the item from the previous step
34 var item = (Item)context.CustomData["Item"] ?? context.VersionToPublish;
35 if (item == null)
36 return;
37
38 // Restrict items to certain templates
39 var template = TemplateManager.GetTemplate(item);
40 // SearchableTemplates is a List<ID>
41 if (!SearchableTemplates.Any(x => template.ID == x))
42 return;
43
44 // Don't publish messages for standard values
45 if (item.ParentID == item.TemplateID)
46 return;
47
48 // Convert item to the model for Algolia
49 var searchItem = new SearchResultsItem()
50 {
51 ObjectID = item.ID.ToString(),
52 Title = item.Fields[FieldNames.Standard.MenuTitle].Value,
53 Content = item.Fields[FieldNames.Base.Content].Value,
54 Description = item.Fields[FieldNames.Standard.ShortDescription].Value,
55 };
56
57 // Init Algolia Client
58 SearchClient client = new SearchClient("<Application ID>", "<API Key>");
59 SearchIndex index = client.InitIndex("<Index Name>");
60
61 // Decide what type of update is going to Algolia
62 var operation = (context.Action == PublishAction.DeleteTargetItem && !context.PublishOptions.CompareRevisions) ? PublishOperation.Deleted : context.Result.Operation;
63 switch (operation)
64 {
65 case PublishOperation.Deleted:
66 // Delete
67 index.DeleteObject(item.ID.ToString());
68 break;
69 case PublishOperation.Skipped:
70 // Skipped
71 break;
72 default:
73 // Created / Update
74 index.SaveObject(searchItem);
75 break;
76 }
77 }
78 }
79}
80

To get our code to run we now need to patch them in using a config file.

1<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/">
2 <sitecore>
3 <pipelines>
4 <publishItem>
5 <processor patch:before="*[@type='Sitecore.Publishing.Pipelines.PublishItem.PerformAction, Sitecore.Kernel']" type="SitecoreAlgolia.DelateAlgoliaItemsAction, SitecoreAlgolia"/>
6 <processor patch:after="*[@type='Sitecore.Publishing.Pipelines.PublishItem.PerformAction, Sitecore.Kernel']" type="SitecoreAlgolia.PublishChangesToAlgoliaAction, SitecoreAlgolia"/>
7 </publishItem>
8 </pipelines>
9 </sitecore>
10</configuration>

And that's it. As you publish changes the Algolia index will get updated and a front end can be implemented against Algolias API's as it would on any other site.

Special thanks to Mike Scutta for his blog post on Data Integrations with Sitecore which served as a basis for the logic here.