DEV Community

Cover image for GEO Automation in Eleventy: JSON-LD, BLUF & Tables Without Manual Markup
Aribu js
Aribu js

Posted on • Originally published at shcho-i-yak.pp.ua

GEO Automation in Eleventy: JSON-LD, BLUF & Tables Without Manual Markup

BLUF - Part 3 of the GEO/SEO 2026 series

  • Problem: manually writing JSON-LD, BLUF blocks, and HTML tables for every post is time-consuming and error-prone.
  • Solution: Nunjucks includes and shortcodes in Eleventy - set up the template once and every new post gets full GEO infrastructure from frontmatter automatically.
  • What we automate: Article Schema, FAQPage Schema, HowTo Schema, BLUF block, HTML tables with data-label, and robots.txt.
  • Setup time: 1.5-2 hours once → 0 minutes per post afterward.
  • Part 1: GEO Technical Architecture: robots.txt, JSON-LD, and Semantic HTML
  • Part 2: Content Engineering for LLMs: Information Density and BLUF Structure

Why Manual Micro-Markup Doesn't Scale

In Part 1 we manually wrote JSON-LD for each Schema type. In Part 2 we added BLUF blocks and HTML tables to each post individually. With a 5-post blog that's acceptable. With 50 posts it becomes technical debt that guarantees drift: one post missing FAQPage Schema, another with a stale author URL, a third without a BLUF.

The right solution: one change in the template = an update across all posts simultaneously.


Step 1. Base Configuration: _data/metadata.json

All Nunjucks templates will pull global site data from here. If the file already exists, verify it contains these fields:

{
  "title": "Your Blog Title",
  "description": "A technical blog about development and AI tools",
  "url": "https://your-domain.com",
  "language": "en",
  "author": {
    "name": "Your Name",
    "url": "https://your-domain.com/about/",
    "email": "contact@your-domain.com"
  },
  "logo": "https://your-domain.com/images/logo.png"
}
Enter fullscreen mode Exit fullscreen mode

Step 2. Article Schema - Automatic for Every Post

Create _includes/schema-article.njk. It reads title, description, date, and thumbnail from the current page's frontmatter and generates valid JSON-LD:

{% raw %}{%- if title and page.url -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "Article",
  "headline": {{ title | dump | safe }},
  "description": {{ description | dump | safe }},
  "datePublished": "{{ date | dateToISO }}",
  "dateModified": "{{ date | dateToISO }}",
  "author": {
    "@type": "Person",
    "name": {{ metadata.author.name | dump | safe }},
    "url": {{ metadata.author.url | dump | safe }}
  },
  "publisher": {
    "@type": "Organization",
    "name": {{ metadata.title | dump | safe }},
    "url": {{ metadata.url | dump | safe }},
    "logo": {
      "@type": "ImageObject",
      "url": {{ metadata.logo | dump | safe }}
    }
  },
  "mainEntityOfPage": {
    "@type": "WebPage",
    "@id": {{ (metadata.url + page.url) | dump | safe }}
  }
  {%- if thumbnail -%}
  ,
  "image": {{ (metadata.url + "/" + thumbnail.image) | dump | safe }}
  {%- endif -%}
}
</script>
{%- endif -%}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

The dateToISO Filter in .eleventy.js

Eleventy doesn't have a built-in ISO filter. Add this to .eleventy.js:

module.exports = function(eleventyConfig) {

  // Filter: Date → ISO 8601 string for JSON-LD
  eleventyConfig.addFilter("dateToISO", (date) => {
    if (!date) return "";
    return new Date(date).toISOString();
  });

  // ... other settings
};
Enter fullscreen mode Exit fullscreen mode

Adding to the Base Layout

In _includes/base.njk or _includes/layouts/post.njk, inside the <head> block:

{% raw %}<head>
  <meta charset="UTF-8">
  <title>{{ title }} | {{ metadata.title }}</title>
  <meta name="description" content="{{ description }}">

  {# ── GEO Schema Markup ──────────────────────────── #}
  {% include "schema-article.njk" %}
  {% include "schema-faq.njk" %}
  {% include "schema-howto.njk" %}

</head>{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Every new post now automatically gets Article Schema with no additional effort.


Step 3. FAQPage Schema via a Frontmatter Array

The idea: FAQ questions and answers are defined directly in the post's frontmatter. The template generates the Schema automatically, and the same data renders the HTML FAQ section on the page.

Frontmatter Structure

---
title: "Post Title"
faq:
  - q: "What is GEO and how does it differ from SEO?"
    a: "GEO is optimization for citation in AI responses; SEO is for ranking in search results."
  - q: "Which AI bots should be allowed in robots.txt?"
    a: "GPTBot, PerplexityBot, Google-Extended, ClaudeBot, anthropic-ai, FacebookBot."
  - q: "Are GEO and SEO compatible?"
    a: "Yes, most GEO techniques reinforce classical SEO."
---
Enter fullscreen mode Exit fullscreen mode

Template _includes/schema-faq.njk

{% raw %}{%- if faq and faq.length -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "FAQPage",
  "mainEntity": [
    {%- for item in faq -%}
    {
      "@type": "Question",
      "name": {{ item.q | dump | safe }},
      "acceptedAnswer": {
        "@type": "Answer",
        "text": {{ item.a | dump | safe }}
      }
    }{% if not loop.last %},{% endif %}
    {%- endfor -%}
  ]
}
</script>
{%- endif -%}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Rendering the FAQ Section from the Same Data

In the post template (in <body>, not <head>):

{% raw %}{%- if faq and faq.length -%}
<section>
  <h2>Frequently Asked Questions</h2>
  {%- for item in faq -%}
  <details>
    <summary><strong>{{ item.q }}</strong></summary>
    <p>{{ item.a }}</p>
  </details>
  {%- endfor -%}
</section>
{%- endif -%}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

One frontmatter array → Schema for Google/AI and HTML for readers. Synchronization is guaranteed.


Step 4. HowTo Schema for Step-by-Step Guides

Add to the frontmatter of guide posts:

howto:
  name: "How to Set Up SSH Deployment"
  totalTime: "PT1H"
  steps:
    - name: "Generate SSH Keys"
      text: "Run ssh-keygen -t ed25519 -C deploy@mysite.com on your local machine."
    - name: "Copy the Key to the Server"
      text: "Use ssh-copy-id -i ~/.ssh/key.pub user@server-ip for authorization."
    - name: "Set Up a Git Bare Repository"
      text: "On the server run mkdir -p ~/repos/mysite.git && git init --bare."
Enter fullscreen mode Exit fullscreen mode

Template _includes/schema-howto.njk

{% raw %}{%- if howto and howto.steps -%}
<script type="application/ld+json">
{
  "@context": "https://schema.org",
  "@type": "HowTo",
  "name": {{ howto.name | dump | safe }},
  "totalTime": {{ howto.totalTime | dump | safe }},
  "step": [
    {%- for step in howto.steps -%}
    {
      "@type": "HowToStep",
      "position": {{ loop.index }},
      "name": {{ step.name | dump | safe }},
      "text": {{ step.text | dump | safe }}
    }{% if not loop.last %},{% endif %}
    {%- endfor -%}
  ]
}
</script>
{%- endif -%}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Step 5. Automatic BLUF Block

Instead of writing the BLUF by hand in every post, move the bullet points into a frontmatter array bluf and let the template render it.

Frontmatter

bluf:
  - "**Goal:** automate GEO markup so we never write JSON-LD by hand."
  - "**Time:** 2 hours of setup, then 0 minutes per new post."
  - "**Stack:** Eleventy (11ty) + Nunjucks includes + addShortcode in .eleventy.js."
Enter fullscreen mode Exit fullscreen mode

Template _includes/bluf.njk

{% raw %}{%- if bluf and bluf.length -%}
<div class="tldr">
  <div class="tldr-title">BLUF - Summary for AI Crawlers</div>
  <ul>
  {%- for item in bluf -%}
    <li>{{ item | markdownify | safe }}</li>
  {%- endfor -%}
  </ul>
</div>
{%- endif -%}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

Include it at the top of the post template, right after <h1>:

{% raw %}<article>
  {# H1 is rendered from frontmatter title via layout - don't duplicate it #}
  {% include "bluf.njk" %}
  {{ content | safe }}
</article>{% endraw %}
Enter fullscreen mode Exit fullscreen mode

The markdownify Filter

// .eleventy.js - render inline markdown in frontmatter strings
const markdownIt = require("markdown-it");
const md = new markdownIt({ html: true });

eleventyConfig.addFilter("markdownify", (str) => {
  if (!str) return "";
  return md.renderInline(String(str));
});
Enter fullscreen mode Exit fullscreen mode

Step 6. Shortcode for GEO Tables

Instead of manually writing <table> with all the data-label attributes every time - one shortcode in .eleventy.js and clean pipe-separated syntax in Markdown.

Registering the Shortcode in .eleventy.js

// Paired shortcode: {% geotable "Header 1,Header 2,Header 3" %}
// Data rows: Value 1 | Value 2 | Value 3
// {% endgeotable %}

eleventyConfig.addPairedShortcode("geotable", function(content, headers) {
  const headerList = headers.split(',').map(h => h.trim());

  // Parse rows - one line = one <tr>
  const rows = content
    .trim()
    .split('\n')
    .filter(row => row.trim().length > 0);

  // Generate thead
  let html = `<table class="cmp-table">\n  <thead>\n    <tr>\n`;
  headerList.forEach(h => {
    html += `      <th>${h}</th>\n`;
  });
  html += `    </tr>\n  </thead>\n  <tbody>\n`;

  // Generate tbody
  rows.forEach(row => {
    const cells = row.split('|').map(c => c.trim());
    html += `    <tr>\n`;
    cells.forEach((cell, i) => {
      const label = headerList[i] || '';
      html += `      <td data-label="${label}">${cell}</td>\n`;
    });
    html += `    </tr>\n`;
  });

  html += `  </tbody>\n</table>`;
  return html;
});
Enter fullscreen mode Exit fullscreen mode

Usage in a Markdown Post

{% raw %}{% geotable "Platform,TTFB,Price/mo,Control" %}
Eleventy + NVMe VPS | <50 ms  | $5-10 | Full
WordPress (shared)  | 800+ ms | $9    | Limited
Wix                 | 400-600 ms | $17 | None
{% endgeotable %}{% endraw %}
Enter fullscreen mode Exit fullscreen mode

The shortcode automatically adds data-label to every cell - mobile CSS card transformation works without any extra changes.


Step 7. Auto-Generating robots.txt

Instead of a static file - a Nunjucks template that substitutes the site URL from metadata.json automatically.

Create robots.njk in the project root:

---
permalink: /robots.txt
eleventyExcludeFromCollections: true
---
# ── Search Engines ────────────────────────────────────────
User-agent: Googlebot
Allow: /

User-agent: Bingbot
Allow: /

# ── AI Crawlers 2026 ──────────────────────────────────────
User-agent: GPTBot
Allow: /

User-agent: ChatGPT-User
Allow: /

User-agent: PerplexityBot
Allow: /

User-agent: Google-Extended
Allow: /

User-agent: ClaudeBot
Allow: /

User-agent: anthropic-ai
Allow: /

User-agent: FacebookBot
Allow: /

Sitemap: {% raw %}{{ metadata.url }}{% endraw %}/sitemap.xml
Enter fullscreen mode Exit fullscreen mode

Eleventy builds it to _site/robots.txt automatically. When the site URL changes in metadata.json, the Sitemap line updates everywhere at once.


Summary: What's Automated and Setup Time

Element Before (manual) After Automation Setup Time
Article Schema ~20 lines in every post Automatic from frontmatter 20 min
FAQPage Schema JSON-LD by hand + duplicated HTML One frontmatter array → Schema + HTML 25 min
HowTo Schema Written separately for each guide YAML steps in frontmatter 20 min
BLUF block HTML div in every post bluf array in frontmatter 15 min
HTML tables 30+ lines of HTML per table {% geotable %} shortcode 20 min
robots.txt Static file Nunjucks template with metadata.url 10 min

Total setup time: ~1.5-2 hours. After that - no manual markup work for new posts.


Implementation Checklist

Before considering the setup complete, verify each item:

  • [ ] _data/metadata.json contains url, author, logo
  • [ ] The dateToISO filter is registered in .eleventy.js
  • [ ] The markdownify filter is registered in .eleventy.js
  • [ ] _includes/schema-article.njk is included in the base layout inside <head>
  • [ ] _includes/schema-faq.njk is included in the base layout
  • [ ] _includes/schema-howto.njk is included in the base layout
  • [ ] _includes/bluf.njk is included in the post template immediately after H1
  • [ ] The geotable shortcode is registered in .eleventy.js
  • [ ] robots.njk with permalink: /robots.txt is in the project root
  • [ ] Verified with Google Rich Results Test after deploying the first post with the new templates

FAQ

Is this approach compatible with both Eleventy v2.x and v3.x?

Yes. All templates and shortcodes are written for Eleventy v2.x and v3.x - the addFilter, addPairedShortcode, and addShortcode APIs have not changed between versions. The markdownify filter requires npm install markdown-it if the package isn't already in your dependencies.

Does | dump | safe correctly escape special characters in JSON-LD?

Yes. Nunjucks's dump filter serializes a string into valid JSON, escaping quotes, backslashes, and special characters. The | dump | safe combination is the standard pattern for inserting dynamic strings into JSON-LD via Nunjucks.

How do I verify the Schema was generated correctly after deployment?

Three tools: Google Rich Results Test - validates Article, FAQPage, and HowTo. Schema Markup Validator - extended validation. Bing Webmaster Tools → "URL inspection" - shows how Copilot sees the markup.

Can the same approach be used for BreadcrumbList Schema?

Yes. Add _includes/schema-breadcrumb.njk with a {% if series and order %} condition and populate values from frontmatter. This is particularly useful for series posts - AI search engines see the relationship between parts and boost the authority of the entire cluster.


What's Next: Part 4

Part 4 - Measuring the GEO Effect: how to systematically track whether ChatGPT Search, Perplexity, and Gemini are citing your site, which metrics replace classical organic CTR, and how to build a monitoring dashboard without paid tools - using only curl, Python, and Google Sheets.

Top comments (0)