In Sitecore JSS you have two options to cache data in your front-end app (besides caching entire page). You can either cache client site: using react context, react state, or for example with apollo client in-memory cache for connected GraphQL query, results are cached until you reload entire page (therefore it can be used to cache static data for sites using react router for internal links). You can also use browser’s session storage/local storage/cookies where your data is persisted, even if you reload entire page (you can check example how to use local storage for caching Sitecore dictionaries in my previous blog).
Another option to cache data in Javascript layer is to keep it in Express.js server used for headless SSR and then pass it to the JSS application, so it can be used in components.
By caching data client’s site your application still needs to call Sitecore CD at least once for every new visit (if data wasn’t stored in browser’s local storage/cookies during previous visit).
By caching data in Javascript server’s site you can reduce number of calls to Sitecore CD and for example make a request to Sitecore once per 10 minutes, independently from number of visits.
Cache Data in Javascript SSR Proxy
To cache the data in server side Javscript, we can divide our work into following tasks:
- Expose data in Sitecore CD server.
- Load and cache the data from Sitecore CD server in express.js server.
- Pass the data from express.js server to our JSS application.
- Use cached data in JSS application.
Let’s solve it one by one:
1. Get data out of Sitecore
Basic option to get data from Sitecore in JSS is to use layout service – endpoint exposed by Sitecore CD server which returns renderings data as JSON which then can be very easily used in JSS components.
Obviously in our case we cannot use layout service, JSS is calling layout service for every page reload/route change and the reason why we want to cache the data in Javascript layer is to actually reduce JSON payload returned from layout service.
So we need to remove data source item from the component which shall use cache and to create new endpoint which returns data from Sitecore. It would be great if we can return data in exactly same structure as layout service, so we don’t have to do too many changes in our front-end components when switching to cache.
Fortunately it’s quite easy to mimic Layout Service API in our custom MVC controller.
We can implement base class with generic methods:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 |
namespace SmartSitecore.Foundation.ContentAccess.Controllers { using System.Collections.Generic; using System.Web.Mvc; using Newtonsoft.Json.Linq; using Sitecore.Data.Items; using Sitecore.JavaScriptServices.ViewEngine.LayoutService; using Sitecore.LayoutService.Mvc.Caching.ETag; using Sitecore.LayoutService.Mvc.Security; using Sitecore.LayoutService.Serialization.Pipelines.GetFieldSerializer; /// <summary> /// Base controller which mimics behaviour of Layout Service and /// returns item's data as json in structure similar to layout service. /// </summary> [RequireSscApiKey(Order = 1)] [ImpersonateApiKeyUser(Order = 2)] [ImpersonateJwtTokenUser(Order = 3)] [EnableApiKeyCors(Order = 4)] [SuppressFormsAuthenticationRedirect(Order = 5)] [ETagFilter(Order = 6)] public abstract class BaseContentController : Controller { private readonly JssItemSerializer itemSerializer; /// <summary> /// Initializes a new instance of the <see cref="BaseContentController"/> class. /// </summary> /// <param name="fieldSerializer">FieldSerializer.</param> public BaseContentController(IGetFieldSerializerPipeline fieldSerializer) { this.itemSerializer = new JssItemSerializer(fieldSerializer) { AlwaysIncludeEmptyFields = true, }; } /// <summary> /// Change item to JObject. /// </summary> /// <param name="item">Item.</param> /// <returns>JObject.</returns> protected virtual JObject ProcessItem(Item item) { if (item == null) { return null; } return JObject.Parse(this.itemSerializer.Serialize(item)); } /// <summary> /// Change items to JArray. /// </summary> /// <param name="items">Items.</param> /// <returns>JArray.</returns> protected virtual JArray ProcessItems(IEnumerable<Item> items) { var jArray = new JArray(); foreach (var item in items) { JObject value = this.ProcessItem(item); JObject jItem = new JObject { ["id"] = item.ID.Guid.ToString(), ["name"] = item.Name, ["displayName"] = item.DisplayName, ["fields"] = value, }; jArray.Add(jItem); } return jArray; } /// <summary> /// Change JObject to ActionResult and pack it into "fields" node. /// </summary> /// <param name="results">object with results.</param> /// <returns>results in "fields" node.</returns> protected virtual ActionResult ResultsAsJson(JObject results) { if (results == null) { return null; } JObject jItem = new JObject(); jItem["fields"] = results; return this.Content(jItem.ToString(), "application/json"); } } } |
Sample specific implementation can look like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
namespace SmartSitecore.Feature.Sample.Controllers { using System.Web.Mvc; using SmartSitecore.Foundation.ContentAccess.Controllers; using Sitecore.LayoutService.Serialization.Pipelines.GetFieldSerializer; /// <summary> /// Sample content controller. /// </summary> public class SampleContentController : BaseContentController { private const string WidgetItemId = "{73495568-324f-4b5c-aa95-efd10eb45899}"; /// <summary> /// Initializes a new instance of the <see cref="SampleContentController"/> class. /// </summary> /// <param name="fieldSerializer">Field serializer.</param> public SampleContentController(IGetFieldSerializerPipeline fieldSerializer) : base(fieldSerializer) { } /// <summary> /// Get widget tabs with content listed in each tab. /// </summary> /// <returns>Widget with tabs as json.</returns> public ActionResult Widget() { var widget = Sitecore.Context.Database.GetItem(WidgetItemId); var json = this.ProcessItem(widget); json["children"] = this.ProcessItems(widget.Children); return this.ResultsAsJson(json); } } } |
In our case we want to return data source item of the widget, which has some tabs defined as child items. We need to register this controller with following class:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
namespace SmartSitecore.Feature.Sample.Pipelines { using System.Web.Mvc; using System.Web.Routing; using Sitecore.Mvc.Pipelines.Loader; using Sitecore.Pipelines; /// <summary> /// Register routes for API controller. /// </summary> public class InitializeSampleRoutes : InitializeRoutes { /// <inheritdoc/> public override void Process(PipelineArgs args) { RouteTable.Routes.MapRoute("Widget", "api/sample/widget", new { controller = "SampleContent", action = "Widget" }); } } } |
and config patch:
1 2 3 4 5 6 7 8 9 |
<configuration xmlns:patch="http://www.sitecore.net/xmlconfig/" xmlns:set="http://www.sitecore.net/xmlconfig/set/" xmlns:role="http://www.sitecore.net/xmlconfig/role/"> <sitecore role:require="Standalone or ContentManagement or ContentDelivery"> <pipelines> <initialize> <processor type="SmartSitecore.Feature.Sample.Pipelines.Initialize.InitializeSampleRoutes, SmartSitecore.Feature.Sample" patch:before="processor[@type='Sitecore.Mvc.Pipelines.Loader.InitializeRoutes, Sitecore.Mvc']"/> </initialize> </pipelines> </sitecore> </configuration> |
Now we should have working API under http(s)://{sitecore-host}/api/sample/widget. Mind that you need to pass same ?sc_apikey={your api key} query string parameter as for layout service, cause we used exactly the same MVC attributes.
2. Load and Cache Data in Express.js Server
To load and cache data in Express.js server layer we can reuse the code which cache Sitecore dictionaries in SSR proxy provided by Sitecore in config.js file and adding it to viewBag
object. We just need to modify createViewBag
function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
createViewBag: (request, response, proxyResponse, layoutServiceData) => { // fetches the dictionary from the Sitecore server for the current language so it can be SSR'ed // has a default cache applied since dictionary data is quite static and it helps rendering performance a lot if (!layoutServiceData || !layoutServiceData.sitecore || !layoutServiceData.sitecore.context) { return {}; } const language = layoutServiceData.sitecore.context.language || 'en'; const site = layoutServiceData.sitecore.context.site && layoutServiceData.sitecore.context.site.name; if (!site) { return {}; } const cacheKey = `${site}_${language}`; const cached = dictionaryCache.get(cacheKey); if (cached) { return Promise.resolve(cached); } return Promise.all([ fetch(`${config.apiHost}/sitecore/api/jss/dictionary/${appName}/${language}?sc_apikey=${config.apiKey}`, { headers: { connection: "keep-alive", }, } ), fetch(`${config.apiHost}/api/sample/widget?sc_apikey=${config.apiKey}&sc_lang=${language}`, { headers: { connection: "keep-alive", }, } ) ]).then(function (responses) { return Promise.all(responses.map(function (response) { return response.json(); })); }).then(function (json) { const viewBag = { dictionary: json[0] && json[0].phrases, widget: json[1], }; dictionaryCache.set(cacheKey, viewBag); return viewBag; }).catch(function (error) { console.log('Error fetching cached data from Sitecore: ' + error); }); }, |
We only modified marked lines, where we fetch dictionaries and our custom API, then if both requests are done we add two entries to viewBag
object. Keep in mind that viewBag
is stored in cache under site and language specific key. To control the cache timeout we can change stdTTL
in dictionaryCache
declaration.
3. Pass Data from Express.js to JSS Application
Now we have our data cached in Express.js layer, but we want to use it somewhere in JSS application, right? To do it we need to check how server calls code from JSS app bundle.
In index.js file of SSR proxy in line:
server.use('*', scProxy(config.serverBundle.renderView, config, config.serverBundle.parseRouteUrl));
server executes renderView
function from JSS app bundle for every request which is not handled by previous middleware (for example static assets). We can find this function in server/server.js inside our bundle code (assuming it was created with jss create command) and viewBag
is one of the parameter of the function.
RenderView
is responsible for following tasks:
- Initialize i18n with Sitecore dictionary loaded from
viewBag
, so it can be used for translation inside React components. - Call server side rendering of JSS app for current state using renderToStringWithData and store results in a string (state contains i.a. layout service results executed for current path).
- Place the string from previous point inside <html> element with title and meta data inside <head> added by react-helmet’s Helmet.renderStatic()
- Serialize current state of layout service (eventually together with connected GraphQL query results) and add it to HTML inside
__JSS_STATE__
variable, so when the app is rendered again client’s side, it doesn’t need to query layout service or call connected GraphQL queries again, cause all the data is already inside__JSS_STATE__
variable.
Finally HTML document generated in RenderView
is returned to the client.
To get the data from viewBag
and pass it to the React components, we can add our logic inside RenderView
, before rendering to string. We move the data to sitecore.context, so it will be available in React components which uses withSitecoreContext()
and stored later in __JSS_STATE__
.
Modifications in server/server.js file (only marked lines):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
export function renderView(callback, path, data, viewBag) { try { const state = parseServerData(data, viewBag); let qQLEndpoint = config.graphqlEndpoint; if (state?.sitecore?.context){ qQLEndpoint=`${config.graphqlEndpoint}&sc_lang=${state.sitecore.context.language}` if (state.viewBag.widget){ //move widget state from viewbag to sitecore.context, so it will be stored in __JSS_STATE__ to make it available for components which uses withSitecoreContext state.sitecore.context.widget = state.viewBag.widget; } } ... //rest of the function |
4. Use Cached Data in JSS App
We can finally use our cached data in sample component:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 |
import React, { useState, useEffect } from 'react'; import axios from 'axios'; import { Text, withSitecoreContext } from '@sitecore-jss/sitecore-jss-react'; import config from '../../../temp/config'; const MyWidget = (props) => { const [widget, setWidget] = useState(props.sitecoreContext?.widget); useEffect(() => { async function fetchData() { const response = await axios({ url: `${config.sitecoreApiHost}/rwsapi/products/widget?sc_apikey=${config.sitecoreApiKey}&sc_lang=${props.sitecoreContext?.language}`, withCredentials: true, }); setWidget(response.data); } if (props.sitecoreContext?.widget) { // we are on headless mode and can read it from the context setWidget(props.sitecoreContext?.widget); } else { // we are in integrated/connected mode, so don't need to worry about extra server call. fetchData(); } }, [props.sitecoreContext.language, props.sitecoreContext.widget]); // execute on load and if language changed return ( <> <div className="title"> <Text field={widget?.fields?.title} /> </div> {widget?.fields?.children?.map((tab) => { return ( <div key={tab} className="tab"> <Text field={tab.fields.title} /> </div> ); })} </> ); }; export default withSitecoreContext()(MyWidget); |
At first glance it may look complicated but actual change comparing to standard component is very simple, we only replaced props.fields.{field} with props.sitecoreContext.widget.{field}. Rest of the code is to support JSS connected and integrated modes (to fetch the data if it wasn’t passed from SSR) which on production code you would move to HOC or custom context.