How To Use Personalized Paragraphs

Pierce Lamb
11 min readApr 9, 2021

--

This blog covers how to set up and use Personalized Paragraphs. If you’re looking for how Personalized Paragraphs was built, check out Personalized Paragraphs: Porting Smart Content to Paragraphs for Drupal. If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel.

You’ve visited the Personalized Paragraphs Module Page and downloaded the module. In doing that, composer should have also downloaded Smart Content, Entity Usage, and Paragraphs. You can verify this by visiting /admin/modules and checking that the modules are there. If they are not enabled, make sure Entity Usage, Paragraphs/Paragraphs Library, Smart Content, Smart Content Browser and Personalized Paragraphs are enabled. Now what?

Segment Sets

The entry point for using Smart Content is the Segment Set. Segment Sets define how you want to segment traffic via given conditions. We’ll be using the conditions out of the Smart Content Browser module as an example for this blog. As such, imagine that you want to segment traffic based on browser width. For your first segment, perhaps you want to segment visitors based on if their browser is greater than 1024px wide, or less than 1024px (I know this is a silly example, but it is nice for basic understanding). So based on the 1024px breakpoint, you want users above this width to see a certain experience and users below it to see a different one. We’ll define this in a Segment Set.

  • Visit /admin/structure/smart_content_segment_set
  • Click ‘Add Global Segment Set’.
  • Give your segment set a label like ‘Browser Width Segments.’
  • Click ‘Add Segment’
  • Give your segment a title like ‘less-than-1024px’
  • In the condition dropdown look for the ‘Browser’ header and select ‘Width’
  • Click ‘Add Condition’
  • Set the width to ‘less than’, ‘1024’px
  • Click ‘Add Segment’
  • Give your segment a title like ‘greater-than-1024px’
  • In the condition dropdown look for the ‘Browser’ header and select ‘Width’
  • Click ‘Add Condition’
  • Set the width to ‘greater than’, ‘1024’px
  • Click ‘Add Condition’
  • In the label area, add ‘Default Segment’
  • Under ‘Common’ select the condition ‘True’
  • Check ‘Set as default segment’
  • Save

With Browser Width Segments in place, we now have a way of segmenting traffic based on the width of the users browser (I recognize we only needed the less-than-1024px and default segments here, but it helps for learning to show all conditions). Users with browsers less than 1024px wide will see one piece of content and users with browsers greater will see a different piece. We added the default segment for clarity; in a situation where a user does not match any segment, it will display.

Personalized Content

Now we have to decide what content we’d like to personalize based on browser width. Let’s say we have a content type called ‘Homepage’ and we want to personalize the banner area of our homepage. Ideally we would use a paragraph to represent the banner and display different banners based on browser width. The first thing we want to do is create the paragraph that will be our banner.

  • Open the homepage node’s structure page (or whichever node you’re personalizing)
  • Take note of the fields it currently contains that constitute the banner
  • In another tab or window, visit /admin/structure/paragraphs_type/add
  • Give it a name like ‘Personalization — Homepage Banner’
  • Make sure to check the box that says ‘Allow adding to library.’ If this box is missing, Paragraphs Library likely is not enabled, check /admin/modules and see if its enabled
  • Inside your new Paragraph, re-create each field that represents the banner from the tab you have open on the homepage.
  • I typically add a boolean field to check if the paragraph is a personalized paragraph and
  • I typically add a campaign ID text field to add a campaign to the paragraph which can be pushed into the dataLayer
  • Neither of these are necessary for this tutorial, but may be to you in the future
  • Save your paragraph
  • Now visit /admin/content/paragraphs (added via Paragraphs Library)
  • Click ‘Add Library Item’
  • Add a label like ‘Homepage Banner — less-than-1024px’
  • Click the paragraph dropdown and select ‘Personalization — Homepage Banner’
  • Fill in the fields
  • Save
  • Click ‘Add Library Item’
  • Add a label like ‘Homepage Banner — greater-than-1024px’
  • Click the paragraph dropdown and select ‘Personalization — Homepage Banner’
  • Fill in the fields
  • Save
  • Do this one more time, but for ‘Homepage Banner — Default Banner’

If you’ve completed these steps, you now have 3 personalized banners that match the 3 segments we created above. Okay, so we have our segment set and our personalized content, now what?

Personalized Paragraphs

The next step is adding a Personalized Paragraph to the node you’re personalizing. In order to do that, we:

  • Add a new field to our homepage node.
  • Select the Add a New Field dropdown, find the ‘Reference Revisions’ header and select ‘Paragraph’
  • Give it a name like ‘Personalized Banner’
  • Save
  • On the ‘Field Settings’ page, under ‘allowed number of values’ select ‘Limited’ — 1 (this may change in the future)
  • Now, on the ‘Edit’ page for this new field, under the ‘Reference Type’ fieldset find and select ‘Personalized Paragraph’
  • Save

If you’ve completed all of these steps (note that you may need to flush caches), you can now load an instance of your homepage node and click ‘edit’ or add a new homepage node and you should see something that looks like this on the page (if not, click the ‘Add Personalized Paragraph button’):

The first step is to give your personalized paragraph a name that is unique within the page like ‘personalized_homepage_banner.’ It is possible to continue while leaving this field blank (and you can change it later), it is used only for identifying a personalized paragraph in front end code. Next we should find our segment set, ‘Browser Width Segments’ in the segment set dropdown and press ‘Select Segment Set.’ After it finishes loading, we should see 3 reactions which match the 3 segments we created earlier. In the paragraph dropdowns, we’ll select the respective paragraph we made for each segment, for e.g. ‘Homepage Banner — less-than-1024px’ will go in the segment titled ‘SEGMENT LESS-THAN-1024PX.’ After saving we will be redirected to viewing the saved page.

You may or may not see any change to your page at this point, it depends on your template file for this page. The content itself will be behind content.field_personalized_banner in your respective template file. One way to see that it’s on the page is to search the html for whatever you named the paragraph type used by Paragraphs Library. In our example it would be personalization-homepage-banner. This string should be found in the classes of an element in the HTML. The key, however, is that you now have access to the winning paragraph in your template file. Here’s an example of how we might display field_personalized_banner in a template file:

{% if content.field_personalized_banner %}
<div class="homepage-decision-paragraph">
<div class="row banner-text banner-has-video">
<div class="columns small-12 medium-8 large-6" style="color:{{ text_color }};">
{{ content.field_personalized_banner }}
</div>
</div>
</div>
{% endif %}

Assuming you are seeing the content of your Personalized Paragraphs on the page, you can change your browser size to either larger than 1024px or less than, refresh, and you should get the other experience. If you don’t this is likely because Smart Content stores information in local browser storage regarding the experience you first received (specifically the _scs variable); it checks this variable before doing any processing for performance reasons. One way around this is to load a fresh incognito window, resize it to the test width and then load your page, another is to go into your local storage and delete the _scs variable.

There are many ways to segment traffic in Smart Content beyond browser conditions, Smart Content provides sub modules for Demandbase, UTM strings and 6sense. I’ve also written a module for Marketo RTP which we will be open sourcing soon. I wrote a blog about it here which can be used as a guide for writing your own connector.

At this point in using Personalized Paragraphs, there are a lot of ways you could go with displaying the front end and you definitely don’t need to read the remainder of this blog. I’ll cover the way my org displays it, but I’ll note that there isn’t some sort of best practice, it is just what works for us. You absolutely do not need to use Personalized Paragraphs in this manner and I encourage you to experiment and find what works for you.

Front end processing

You may have noticed that our personalized banner field is ‘overwriting’ the existing banner fields on our homepage node (as opposed to replacing). That is, our original banner fields and personalized banner field are doing the same ‘thing’ and now we have two of them on the page. The reason for this decision is based on how Smart Content works. When the page is loaded Smart Content decides which paragraph has won then uses ajax to retrieve that paragraph and load it onto the page. If we replaced the original banner with the Personalized Paragraph, the banner would appear to ‘pop-down’ after the page had begun loading whenever the ajax returned. We felt that this experience was worse than the small performance hit of loading both banners onto the page (it also provides a nice fall back should anything fail). This ‘pop-down’ effect of course only occurs when you’re personalizing a portion of the page that contributes to the flow; if it was an element that doesn’t you’d see more of a ‘pop-in’ effect which is less jarring.

Because we are loading two of the same ‘things’ onto the page, we need to use javascript to decide which one gets displayed. We need a way to inform some JS code of whether or not a decision paragraph is on the page (else display fallback) and also differentiate between which decision paragraph we’re operating on (in case there is more than one on the page). The entry point for this goes back to how Personalized Paragraphs was built, to a more controversial point near the end. In a hook_preprocess_field__entity_reference_revisions function we load the Personalized Paragraph onto the page then execute this code:

$para_data = [
'token' => $token,
];
$has_name = !$para->get('field_machine_name')->isEmpty();
$name = $has_name ? $para->get('field_machine_name')->getValue()[0]['value'] : '';
$variables['items'][0]['content']['#attached']['drupalSettings']['decision_paragraphs'][$name] = $para_data;

In essence, this code stores the information we’re after in drupalSettings so we have access to it in javascript. We get a key, [‘decision_paragraphs’] which contains an associative array of personalized paragraphs machine names which have their decision tokens as values. With this information we can now manipulate the front end as we need.

Before I show how we display the default or the winner on the front end, I want to reiterate that this is not a best practice necessarily; it’s just a design decision that works for us. All of the below code is stored in a custom module specifically for personalization. First, we create a js file called ‘general_personaliztion.js’ that will be attached on any page where Personalized Paragraphs run. To continue following our example, we add this at the top of our homepage template:

{{ attach_library(‘<module_name>/general_personalization_js’) }}

Following the style much of the smart content javascript is written in, this file defines an object that can be accessed later by other JS files:

Drupal.behaviors.personalization = {};

Drupal.behaviors.personalization.test_for_decision_paragraph =
function(paragraphs_and_functions, settings) {
...
};

This first function will test if a decision paragraph is on the page:

function(paragraphs_and_functions, settings) {
if(settings.hasOwnProperty('decision_paragraphs')) {
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
if (!settings.decision_paragraphs.hasOwnProperty(paragraph_name)) {
var show_default = display_paragraphs['default'];
show_default();
}
});
} else {
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
var show_default = display_paragraphs['default'];
show_default();
});
}
};

It first tests to see if the key ‘decision_paragraphs’ exists in the passed settings array, if it doesn’t it takes the passed paragraphs_and_functions Map, iterates over each Personalized Paragraph machine name, grabs the function out of it that displays the default experience and executes it. If ‘decision_paragraphs’ does exist, it does the same iteration checking to see if the Map of machine names passed to it are represented in that decision_paragraphs key, if not it gets their default function and executes it. This means we can change machine names, delete personalized paragraphs etc and guarantee that the default experience will display no matter what we do. So how do we call this function with the right parameters?

We create a new file, personalization_homepage.js which is now attached directly underneath the previous file in the homepage template:

{{ attach_library('<module_name>/general_personalization_js') }}
{{ attach_library(‘<module_name>/personalization_homepage') }}

This file will only ever be attached to the homepage template. Inside this file, we create an object to represent the default and personalized experiences for our banner:

var banner_display_functions = {
'default': personalization_default_banner,
'personalized': personalization_set_banner
};

The values here are functions defined elsewhere in the file that execute the JS necessary to display either the default or personalized experiences. Next we create a Map like so:

var paragraphs_and_functions = new Map([
['personalization_homepage_banner', banner_display_functions],
]);

The map has personalized paragraph machine names as keys and the display object as values. You can imagine with another Personalized Paragraph on the page, we’d just add it as a member here. An unfortunate side effect of this design is the hardcoding of those machine names in this JS file. I’m sure there is a way around this, but for performance reasons and how often we change these paragraphs, this works for us. With this in place, calling our test_for_decision_paragraphs function above is straight forward:

Drupal.behaviors.personalizationHomepage = {
attach: function (context, settings) {
if (context === document) { Drupal.behaviors.personalization.test_for_decision_paragraph(paragraphs_and_functions, drupalSettings);
}

...
}

This populates test_for_decision_paragraph with the correct values. It is inside context === document to ensure that it executes at only the right time (without this control the default banner will ‘flash’ multiple times). So now the code is in place to test for a decision paragraph and display the default experience if it is not there. What about displaying the winning personalized experience?

We create another function inside general_personalization.js, ‘test_for_winner’:

Drupal.behaviors.personalization.test_for_winner =
function(current_decision_para_token, paragraphs_and_functions) {
//Iterate over the display blocks, matching display block UUID to decision_block_token
paragraphs_and_functions.forEach((display_paragraphs, paragraph_name) => {
if(drupalSettings.decision_paragraphs.hasOwnProperty(paragraph_name)) {
var decision_paragraph_token = drupalSettings.decision_paragraphs[paragraph_name].token;
if (decision_paragraph_token === current_decision_para_token) {
var show_winner = display_paragraphs['personalized'];
show_winner($(event.detail.response_html))
}
}
});

};

(note it may be event.detail.data for you)

Remember how we talked about that hook_preprocess_hook in which we attached the decision token of a personalized paragraph keyed by machine name? Our test_for_winner iterates those machine names extracting the decision token and comparing it to the decision token of the current winning paragraph; when a match is found, it uses the winning machine name to find and run the function that displays the personalized experience and executes it. So how do we call this function? Inside the same Drupal.behaviors.personalizationHomepage{…} in personalization_homepage.js we add:

window.addEventListener('smart_content_decision', function (event) {
Drupal.behaviors.personalization.test_for_winner(event.detail.decision_token, paragraphs_and_functions);
});
}

(note: we’ve made some edits to the broadcasted event object internally. I invite you to print this object and see what it contains.

And here is where we touch one of the areas Smart Content creates an offering for front end processing: when a winner is chosen by Smart Content, it broadcasts a ‘smart_content_decision’ event which contains a bunch of information including the decision token of the winning content. This is what test_for_winner uses to compare to existing personalized paragraphs to select a display function. Smart Content will broadcast this event for every winning paragraph (e.g. if we have n personalized paragraphs on the page, the event will broadcast n times with each paragraph’s winning token) and test_for_winner allows us to know which paragraph it’s currently broadcasting for and allows us to execute that paragraphs personalized display function.

There are a number of other cool things we can do with this broadcasted event, for example, pushing the campaign name of the winning paragraph into the dataLayer, but I will leave this for another time.

I hope that the example we’ve used throughout has helped you to better understand how to use Personalized Paragraphs and the brief tour of our front end design has given you a starting point.

If you’re looking for how Personalized Paragraphs was built, check out Personalized Paragraphs: Porting Smart Content to Paragraphs for Drupal. If you have any questions you can find me @plamb on the Drupal slack chat, there is also a #personalized_paragraphs channel.

--

--

Pierce Lamb

Data & Machine Learning Engineer at a Security startup