---
type: Article
title: Creating a modular approach to json schema in an eleventy website
description: 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 to populate the schema.
resource: https://www.simoncox.com/post/2025-10-28-creating-a-modular-approach-to-json-schema-in-an-eleventy-website/
tags: [Post, SEO, Eleventy]
timestamp: 2025-10-28
---

Having enjoyed  [Kaj Kandler’s presentation at the 25th 11ty meetup July 2025](https://11tymeetup.dev/events/ep-25-structured-data-and-deploying-11ty/) 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.
`{% raw %}{%- include "schema/schema.njk" -%}{% endraw %}`
	

## The schema.njk file
The file that pulls the schema all together is 

The full schema file:
```
{% raw %}{%- 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>{% endraw %}
```


### Initial set up
The file starts with an initial set up
```
{% raw %}{%- set graph = [] %}{% endraw %}
```

This creates an empty array called graph that will hold all the schema objects.

### Including Base Schema Components
```
{% raw %}{# Include SiteNavigationElement #}
{%- set sitenavSchema %}{%- include "schema/SiteNavigationElement.njk" %}{%- endset %}{% endraw %}
```

This pattern (repeated for multiple schemas) does three things:
1. {% raw %}{%- set sitenavSchema %}...{%- endset %}{% endraw %} captures the output of the included template into a variable
2. {% raw %}{%- include "schema/SiteNavigationElement.njk" %}{% endraw %} includes the contents of an external template file
3. The - in {% raw %} {%- {% endraw %} 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

```
{% raw %}{%- set graph = graph.concat([sitenavSchema,websiteSchema, personSchema, organizationSchema, webpageSchema]) %}{% endraw %}
```
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.

```
{% raw %}{%- if page.url.startsWith("/post/") %}
  {%- set articleSchema %}{%- include "schema/article.njk" %}{%- endset %}
  {%- set graph = graph.concat([articleSchema]) %}{% endraw %}
```

and for the Shorticles in short-articles i decided to use a blog schema instead of article.
```
{% raw %}{%- elif page.url.startsWith("/short-articles/") %}
  {%- set blogpostSchema %}{%- include "schema/blogpost.njk" %}{%- endset %}
  {%- set graph = graph.concat([blogpostSchema]) %}
{%- endif %}{% endraw %}
```

### 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:

```
{% raw %}{%- if page.url == "/post/" %}
  {%- set collectionPostSchema %}{%- include "schema/collection-post.njk" %}{%- endset %}
  {%- set graph = graph.concat([collectionPostSchema]) %}{% endraw %}
```
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

```
{% raw %}{%- set breadcrumbSchema %}{%- include "schema/breadcrumb.njk" %}{%- endset %}
{%- set graph = graph.concat([breadcrumbSchema]) %}{% endraw %}
```

This adds breadcrumb navigation schema to every page.

### Final JSON-LD Output
Finally it pulls together the elements into a JSON-LD script
```
{% raw %}<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@graph": [
    {{ graph | join(",\n") | safe }}
  ]
}
</script>{% endraw %}
```

- 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
- `{% raw %}{{ graph | join(",\n") | safe }}{% endraw %}` 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

```
{% raw %}{

"@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 }}"

}

}{% endraw %}
```

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.
```
{% raw %}{

"@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 -%}

]

}{% endraw %}
``` 

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](/simons-schema/).

[Read full article](https://www.simoncox.com/post/2025-10-28-creating-a-modular-approach-to-json-schema-in-an-eleventy-website/)
