How can I dynamically add FAQ sections on a product page?

Topic summary

A developer is working on a product page feature that allows merchants to dynamically add FAQ sections organized by product categories. The challenge involves creating multiple FAQ items under each category, with varying quantities per category.

Core Problem:

  • Shopify sections only support one level of nesting (blocks within sections)
  • Multi-level structures (blocks within blocks) are not natively supported
  • Initial attempts using nested blocks resulted in invalid JSON schema errors

Solution Implemented:
The developer resolved this by using a dropdown approach:

  • Create a section for category names (A, B, C)
  • Add FAQ blocks with a dropdown field to assign each FAQ to a specific category
  • Use conditional logic in Liquid to filter and display FAQs based on their assigned category

Trade-offs:

  • Requires merchants to manually assign each FAQ to a category via dropdown
  • Management becomes cumbersome with many products/FAQs
  • Acceptable for limited scope (3 products, <30 FAQs in this case)

Alternative Approach Suggested:
Another user recommends using “block-start” and “block-end” custom blocks with counter variables and boolean toggles to simulate nesting without JavaScript, potentially offering cleaner code and improved functionality.

Summarized with AI on November 25. AI used: claude-sonnet-4-5-20250929.

I’m trying to build a section managed part of a product page, where the merchant can input a bunch of FAQ question/answer items based on product type. The mockup has 3 different categories, each with their own list of FAQs.

I’m a bit stuck on how to make the number of FAQ’s under each section be dynamically added (ex, Formula could have 9 FAQs, but Porridge could have 2).

Currently, the below code just adds a bunch of buttons, and returns an "invalid JSON tag in ‘schema’.

It seems like section blocks only go 1 level deep from my experience. Is there a way to create a multi level section? Any suggestions on how to rework this?

[screenshot of shopify section][https://imgur.com/a/LcWEXZ6]

[mockup][https://imgur.com/a/BABFRwK]

<div class="faqs">
	{% for block in section.blocks %}
	{%- assign titleIndex = 0 -%}	
		
		{% if block.type == "faqHeading" %}
		  <h2 class="title faq--heading">{{ block.settings.faqHeading }}</h2>
		{% endif %}

		<div data-tab-component>
		  <div role="tablist" aria-label="Tabbed content">
			{% if block.type == "faqSection" %}
			    <button role="tab" 
			            aria-selected="true" 
			            aria-controls="tab1-content" 
			            id="tab1"
			            class="tab__button">
			      {{ block.settings.faqSection }}
			    </button>
		    {% endif %}
		</div>
	  
	  {%- assign contentIndex = 0 -%}
			
			{% if block.type == "faqSection" %}	  	
				{%- assign contentIndex = 0 | plus: 1 -%}	
				<section id="faq__tab-content tab{{ contentIndex }}-content" 
			        	role="tabpanel" 
			        	aria-labelledby="tab{{ contentIndex }}" 
			        	tabindex="0"
			        	aria-hidden="true">

			        	{%- assign faqIndex = 0 -%}
			        	{% for block in section.blocks %}
				        	{% if block.type == "FAQQandA" %}
					        	<dl class="faqAccordion">
					        		{% for block in section.blocks %}
					        			{%- assign faqIndex = 0 | plus: 1 -%}
						        		<dt><button type="button" aria-controls="panel-01" aria-expanded="true">{{ block.settings.faqQuestion }}</button></dt>
								        	
							            <dd id="panel-0{{ faqIndex }}" aria-hidden="false">
							              {{ block.settings.faqAnswer }}
							            </dd>
						            {% endfor %}
					            </dl>
				        	{% endif %}
			        	{% endfor %}
				</section>
			{% endif %}
	{% endfor %}
</div>

{% schema %}
{
	"blocks": [
		{
			"name": "FAQ Heading",
			"type": "FAQHeading",
			"limit": 1,
			"settings": [
				{
					"type": "text",
					"id": "faqHeading",
					"label": "FAQ Title",
					"default": "FAQs"
				}
			]
		},
		{
		  "name": "FAQ Section ",
		  "type": "faqSection",
		  "settings" : [
			    {
			    	"type": "text",
			  		"id": "faqSection",
			    	"label": "FAQ Product Type"
					"blocks": [
					    {
							"type": "text",
							"name": "Question"
							"settings": [
								{
									"type": "text",
									"label": "Enter Question",
									"id": "faqQuestion",
									"default": "FAQ question content goes here"
								},
								{
									"type": "textarea",
									"label": "Enter Answer",
									"id": "faqAnswer",
									"default": "Lorem ipsum dolor amit icit"
								}
							]
					    } 
				    ]
			    }
			]
		}
	]
}
{% endschema %}

Answering my own question in case anyone needs an answer!

Short answer: no, sections can only go 1 level. Shopify doesn’t support multi-level sections.

Long answer: You can’t associate sections to other sections. For this use case however, making a dropdown list of 2 options, I was able to pass the selected option from that dropdown into the FAQ to make it associated to the correct category. It’s an extra step and is important to note that this would be a nightmare for the merchant to manage if this approach was used for multiple products. Only 3 products are here and less the 30 FAQ’s, so the tradeoff was acceptable in this scenario.

So, [section 2] - name the category:

category Names

[text area] A

[text area] B

[text area] C

Section 2 - FAQ

assign to a category

[DROPDOWN LIST. Options: A, B C]

[FAQ Question]

[FAQ Answer]

And the loop would look for the category name in an if statement, and then return the code.

<div class="faqs" itemscope itemtype="https://schema.org/FAQPage">
	{% for block in section.blocks %}
		{% if block.type == "FAQHeading" %}
		  <h1 class="title faq--heading text-center">{{ block.settings.faqHeading }}</h1>
		{% endif %}
	{% endfor %}

	<div data-tab-component>
		<div class="faq__tabList" role="tablist" aria-label="Tabbed sections">
			{% for block in section.blocks %}
				{% if block.type == "faqCategories" %}
				    <button role="tab" 
				            aria-selected="true" 
				            aria-controls="tab1-content" 
				            id="tab1"
				            class="tab__button">
				      {{ block.settings.faqProductType1 }}
				    </button>
				    <button role="tab" 
				    		aria-selected="false" 
				            aria-controls="tab2-content" 
				            id="tab2"
				            class="tab__button">
				      {{ block.settings.faqProductType2 }}
				    </button>
				    <button role="tab" 
				    		aria-selected="false" 
				            aria-controls="tab3-content" 
				            id="tab3"
				            class="tab__button">
				      {{ block.settings.faqProductType3 }}
				    </button>
				{% endif %}
			{% endfor %}
		</div>

				
				
				
		<section 
			class="faq__tabPanel"
			id="tab1-content" 
	    	role="tabpanel" 
	    	aria-labelledby="tab1" 
	    	tabindex="0">
				
		    <div data-tab-component>
				<div role="tablist" aria-label="Tabbed sections">
					<dl class="faqAccordion">
						{% for block in section.blocks %}	       
			   				{%- assign type = block.settings.productType -%} 
							
							{%- if type contains "CategoryA"  -%}
								 
					            <dt 
					            	itemscope 
					            	itemprop="mainEntity" 
					            	itemtype="https://schema.org/Question">

						            <button 
											itemprop="name"
						            		role="tab" 
								            aria-selected="true" 
								            aria-controls="faq{{ block.id }}-content" 
								            id="faq{{ block.id }}"
								            class="accordion_heading faq-list">
							        		{{ block.settings.faqQuestion }}
					        		</button>
				        		</dt>
				        		<dd 
									itemscope 
									itemprop="acceptedAnswer" 
									itemtype="https://schema.org/Answer"
				        			class="success_stories_data"
					            	role="tabpanel" 
					            	id="faq{{ block.id }}-content"
							    	aria-labelledby="faq{{ block.id }}" 
							     	tabindex="0"
							     	aria-hidden="true">
							    	<p itemprop="text">
							    		{{ block.settings.faqAnswer }}
							    	</p>
							   </dd>

			  				{%- endif -%}
			            {% endfor %}
		        	</dl>
	            </div>
	        </div>

		</section>

		<section id="tab2-content" 
	       role="tabpanel" 
	       aria-labelledby="tab2" 
	       tabindex="0"
	       aria-hidden="true">
			<div data-tab-component>
				<div role="tablist" aria-label="Tabbed sections">
					<dl class="faqAccordion">
						{% for block in section.blocks %}	       
			   				{%- assign type = block.settings.productType -%} 
							
							{%- if type contains "CategoryB"  -%}
								 
					            <dt 
					            	itemscope 
					            	itemprop="mainEntity" 
					            	itemtype="https://schema.org/Question">

						            <button 
											itemprop="name"
						            		role="tab" 
								            aria-selected="true" 
								            aria-controls="faq{{ block.id }}-content" 
								            id="faq{{ block.id }}"
								            class="accordion_heading faq-list">
							        		{{ block.settings.faqQuestion }}
					        		</button>
				        		</dt>
				        		<dd 
									itemscope 
									itemprop="acceptedAnswer" 
									itemtype="https://schema.org/Answer"
				        			class="success_stories_data"
					            	role="tabpanel" 
					            	id="faq{{ block.id }}-content"
							    	aria-labelledby="faq{{ block.id }}" 
							     	tabindex="0"
							     	aria-hidden="true">
							    	<p itemprop="text">
							    		{{ block.settings.faqAnswer }}
							    	</p>
							   </dd>

			  				{%- endif -%}
			            {% endfor %}
		        	</dl>
	            </div>
	        </div>
		</section>
		<section id="tab3-content" 
	       role="tabpanel" 
	       aria-labelledby="tab3" 
	       tabindex="0"
	       aria-hidden="true">
			<div data-tab-component>
				<div role="tablist" aria-label="Tabbed sections">
					<dl class="faqAccordion">
						{% for block in section.blocks %}	       
			   				{%- assign type = block.settings.productType -%} 
							
							{%- if type contains "CategoryC"  -%}
								 
					            <dt 
					            	itemscope 
					            	itemprop="mainEntity" 
					            	itemtype="https://schema.org/Question">

						            <button 
											itemprop="name"
						            		role="tab" 
								            aria-selected="true" 
								            aria-controls="faq{{ block.id }}-content" 
								            id="faq{{ block.id }}"
								            class="accordion_heading faq-list">
							        		{{ block.settings.faqQuestion }}
					        		</button>
				        		</dt>
				        		<dd 
									itemscope 
									itemprop="acceptedAnswer" 
									itemtype="https://schema.org/Answer"
				        			class="success_stories_data"
					            	role="tabpanel" 
					            	id="faq{{ block.id }}-content"
							    	aria-labelledby="faq{{ block.id }}" 
							     	tabindex="0"
							     	aria-hidden="true">
							    	<p itemprop="text">
							    		{{ block.settings.faqAnswer }}
							    	</p>
							   </dd>

			  				{%- endif -%}
			            {% endfor %}
		        	</dl>
	            </div>
	        </div>
		</section>
	</div>
</div>

{% schema %}
{
	"blocks": [
		{
			"name": "FAQ Heading",
			"type": "FAQHeading",
			"limit": 1,
			"settings": [
				{
					"type": "text",
					"id": "faqHeading",
					"label": "FAQ Title",
					"default": "FAQs"
				}
			]
		},
		{
			"name": "FAQ Categories",
			"type": "faqCategories",
			"settings": [
				{
					"type": "text",
					"label": "faqProductType1",
					"id": "faqProductType1",
					"default": "Formula"
				},
				{
					"type": "text",
					"label": "faqProductType2",
					"id": "faqProductType2",
					"default": "Porridge"
				},
				{
					"type": "text",
					"label": "faqProductType3",
					"id": "faqProductType3",
					"default": "Snack Puffs"
				}
			]
		},
		{
			"name": "FAQ Q and A",
			"type": "faqQandA",
			"settings": [
				{
					"type": "select",
					"id": "productType",
					"options": [
				      { "value": "CategoryA", "label": "CategoryA"},
				      { "value": "CategoryB", "label": "CategoryB"},
				      { "value": "CategoryC", "label": "CategoryC"}
					],
					"label": "Select Product"					
				},
	            {
	                "type": "text",
	                "label": "Enter Question",
	                "id": "faqQuestion",
	                "default": "FAQ question content goes here"
	            },
	            {
	                "type": "textarea",
	                "label": "Enter Answer",
	                "id": "faqAnswer",
	                "default": "Lorem ipsum dolor amit icit"
	            }
			]
		}
	]
}
{% endschema %}

I’m sure there’s a clever way to nest another loop so you would only have to write the output HTML for the tabs and accordion groups once which would make this more maintainable.

This is also a WCAG AA accessible accordion structure. Here’s the Vanilla JS.

{% javascript %}
/**
 * @function Tabs()
 *
 * @param args {object} Settings for controlling the functionality of the component
 * @returns bindEventListeners {function} Event listeners for the component
 */

function Tabs(args) {
  // Scope-safe constructors
  if (!(this instanceof Tabs)) { 
    return new Tabs();
  }
  
  /**
   * Default component settings
   *
   * @param container {string} Classname for container of the entire component
   * @param trigger {string} Element that toggles content
   * @param content {string} Classname for the content
   */
  var defaults = {
    container: '[data-tab-component]',
    trigger: '[role="tab"]',
    content: '[role="tabpanel"]'
  };

  // If there are no settings overrides
  var settings = (typeof args !== 'undefined') ? args : defaults;

  /**
   * @function toggle()
   *
   * Handles the displaying/hiding of content
   *
   * @returns null
   */
  var toggle = function() {
    var parent = this.closest(settings.container),
        target = this.getAttribute('aria-controls'),
        content = document.getElementById(target),
        toggles = parent.querySelectorAll(settings.trigger),
        all_content = parent.querySelectorAll(settings.content);

    // Update visibility
    for (var i = 0, len = toggles.length; i < len; i++) {
      toggles[i].setAttribute('aria-selected', 'false');
      all_content[i].setAttribute('aria-hidden', 'true');
    }
    
    this.setAttribute('aria-selected', 'true');
    content.setAttribute('aria-hidden', 'false');
  };

  /**
   * @function bindEventListeners()
   *
   * Attach event listeners
   *
   * @returns null
   */
  var bindEventListeners = function() {
    var trigger = document.querySelectorAll(settings.trigger);
    
    //
    // TODO
    // Use event delgation to add event handlers
    //
    for (var i = 0, len = trigger.length; i < len; i++) {
      trigger[i].addEventListener('click', function(event) {
        toggle.call(this);
      });
      
      trigger[i].addEventListener('keydown', function(event) {
        if (event.which == 13) {
          toggle.call(this);
        }
      });
    };
  };

  return bindEventListeners();
}

// Create an instance of component
window.onload = function() {
  var tabs = new Tabs();
};
{% endjavascript %}
3 Likes

Was looking for a way to nest blocks - since it’s only normal to have such basic functionality…

I think it’s better off to just add custom blocks to
“start” and “end”

{
"type": "block-start",
"name": "Block Start"
},
{
"type": "block-end",
"name": "Block End"
},

Then you just do some math magic.

When the block starts you set a counter to 0, and toggle a bool to true.
on the regular block for each, you then test the variable and add to the counter if true.

When the block ends you toggle the bool.

after the for loop, if the bool is still true, you know the user forgot the closing tag, so you add it for him.

It should give you more or less everything with less code and improved functionality.

No JS invovled either at a base level.