Creating a modular approach to json schema in an eleventy website
Having enjoyed Kaj Kandler’s presentation at the 25th 11ty meetup July 2025 I wanted a modular and automated way to add json schema to my personal website so built a set of templates and some simple logic.
Schema files Structure in 11ty
I have created a schema directory in the includes directory and keep all the files associated with this in there.
This has a main schema.njk file that is pulled into my base template which I use for all pages.
src/_includes/base.njk
In the base.njk I have this following include after the pages footer and before the body closes.
{%- include "schema/schema.njk" -%}
The schema.njk file
The file that pulls the schema all together is
The full schema file:
{%- set graph = [] %}
{# Include SiteNavigationElement #}
{%- set sitenavSchema %}{%- include "schema/SiteNavigationElement.njk" %}{%- endset %}
{# Include base fragments #}
{%- set websiteSchema %}{%- include "schema/website.njk" %}{%- endset %}
{%- set personSchema %}{%- include "schema/person.njk" %}{%- endset %}
{%- set organizationSchema %}{%- include "schema/organization.njk" %}{%- endset %}
{%- set webpageSchema %}{%- include "schema/webpage.njk" %}{%- endset %}
{%- set graph = graph.concat([sitenavSchema,websiteSchema, personSchema, organizationSchema, webpageSchema]) %}
{# Conditionally include article or blogpost schemas #}
{%- if page.url.startsWith("/post/") %}
{%- set articleSchema %}{%- include "schema/article.njk" %}{%- endset %}
{%- set graph = graph.concat([articleSchema]) %}
{%- elif page.url.startsWith("/short-articles/") %}
{%- set blogpostSchema %}{%- include "schema/blogpost.njk" %}{%- endset %}
{%- set graph = graph.concat([blogpostSchema]) %}
{%- endif %}
{# CollectionPage for /post/, /short-articles/, or /narrow-gauge-modelling/ list pages #}
{%- if page.url == "/post/" %}
{%- set collectionPostSchema %}{%- include "schema/collection-post.njk" %}{%- endset %}
{%- set graph = graph.concat([collectionPostSchema]) %}
{%- elif page.url == "/short-articles/" %}
{%- set collectionShortsSchema %}{%- include "schema/collection-shorts.njk" %}{%- endset %}
{%- set graph = graph.concat([collectionShortsSchema]) %}
{%- elif page.url == "/narrow-gauge-modelling/" %}
{%- set collectionNGMSchema %}{%- include "schema/collection-ngm.njk" %}{%- endset %}
{%- set graph = graph.concat([collectionNGMSchema]) %}
{%- endif %}
{# Breadcrumb #}
{%- set breadcrumbSchema %}{%- include "schema/breadcrumb.njk" %}{%- endset %}
{%- set graph = graph.concat([breadcrumbSchema]) %}
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{{ graph | join(",\n") | safe }}
]
}
</script>
Initial set up
The file starts with an initial set up
{%- set graph = [] %}
This creates an empty array called graph that will hold all the schema objects.
Including Base Schema Components
{# Include SiteNavigationElement #}
{%- set sitenavSchema %}{%- include "schema/SiteNavigationElement.njk" %}{%- endset %}
This pattern (repeated for multiple schemas) does three things:
- {%- set sitenavSchema %}...{%- endset %} captures the output of the included template into a variable
- {%- include "schema/SiteNavigationElement.njk" %} includes the contents of an external template file
- The - in {%- removes whitespace before/after the tag for cleaner output
The same pattern is then repeated for:
- websiteSchema - Website information
- personSchema - Person/author information
- organizationSchema - Organization/company information
- webpageSchema - Basic webpage information
{%- set graph = graph.concat([sitenavSchema,websiteSchema, personSchema, organizationSchema, webpageSchema]) %}
This adds all the base schemas to the graph array.
Conditional Article/Blog Post Schemas
Then I start setting up schemas for types of page. All my article pages are in the post directory so anything in there will need the article schema.
{%- if page.url.startsWith("/post/") %}
{%- set articleSchema %}{%- include "schema/article.njk" %}{%- endset %}
{%- set graph = graph.concat([articleSchema]) %}
and for the Shorticles in short-articles i decided to use a blog schema instead of article.
{%- elif page.url.startsWith("/short-articles/") %}
{%- set blogpostSchema %}{%- include "schema/blogpost.njk" %}{%- endset %}
{%- set graph = graph.concat([blogpostSchema]) %}
{%- endif %}
Collection Page Schemas
For the top level collection pages of lists - such as the articles list and shorticles list pages I have set a collections schema:
{%- if page.url == "/post/" %}
{%- set collectionPostSchema %}{%- include "schema/collection-post.njk" %}{%- endset %}
{%- set graph = graph.concat([collectionPostSchema]) %}
These conditions check for exact URL matches to collection/listing pages:
- /post/ - Main posts listing page
- /short-articles/ - Short articles listing page
- /narrow-gauge-modelling/ - A specific category listing page
Each gets its own collection schema added to the graph.
Breadcrumb Schema
Breadcrumb schema is a tricky one to fathom and the logic is in the breadcrumb.njk file
{%- set breadcrumbSchema %}{%- include "schema/breadcrumb.njk" %}{%- endset %}
{%- set graph = graph.concat([breadcrumbSchema]) %}
This adds breadcrumb navigation schema to every page.
Final JSON-LD Output
Finally it pulls together the elements into a JSON-LD script
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph": [
{{ graph | join(",\n") | safe }}
]
}
</script>
- Creates a script tag with type="application/ld+json" (tells search engines this is structured data)
- Sets the Schema.org context
- Creates a @graph array containing all the collected schemas
{{ graph | join(",\n") | safe }}joins all array elements with commas and newlines, and marks it as safe HTML (won't be escaped)
The result is a single script tag containing all relevant structured data for the current page, helping search engines understand the content and potentially enabling rich snippets in search results.
Individual Schema files
The individual schema types have their own file in:
src/_includes/schema/
which contains these files
article.njk
blogpost.njk
breadcrumb.njk
collection-post.njk
collection-shorts.njk
contactpage.njk
copyright.njk
faqpage.njk
imageobject.njk
organisation.njk
person.njk
schema.njk – the main file
webpage.njk
website.njk
Each of these files created the schema JSON so more can be added as you require. I have listed the code for a couple below to give you the general idea of how they go together.
Article schema file
The article.njk schema file in full
{
"@type": "Article",
"@id": "{{ site.url }}{{ page.url }}#article",
"headline": "{{ title }}",
"datePublished": "{{ page.date.toISOString() }}",
"dateModified": {% if dateUpdated %}"{{ dateUpdated.toISOString() }}"{% else %}"{{ page.date.toISOString() }}"{% endif %},
"image": {
"@type": "ImageObject",
"url": "{{ site.url }}{{ image }}",
"inLanguage": "en-GB"
},
"author": {
"@type": "Person",
"@id": "{{ site.url }}/#person",
"name": "Simon Cox"
},
"mainEntityOfPage": {
"@id": "{{ site.url }}{{ page.url }}"
}
}
I am pulling frontmatter data as well as global data - though top make it more transferrable items like name: Simon Cox could have used a placeholder instead of being hardcoded. i should clean that up so I can use this on other sites!
The collection-post schema file
I'm sure I could use some logic to simplify the number of files but this works for me at the moment.
{
"@type": "CollectionPage",
"@id": "{{ site.url }}/post/#webpage",
"url": "{{ site.url }}/post/",
"name": "Articles",
"description": "A collection of in-depth articles by Simon Cox.",
"isPartOf": {
"@id": "{{ site.url }}/#website"
},
"hasPart": [
{%- for post in collections.post -%}
{
"@type": "Article",
"@id": "{{ site.url }}{{ post.url }}#webpage"
}{%- if not loop.last %},{% endif %}
{%- endfor -%}
]
}
I hope that helps someone put together their own schema on an 11ty site!
To check your progress you could install my schema chrome extension.
Previous post: What to do with keywords that have dropped out of the rankings





