Templating Versus Components: A False Trade-Off

Static site generators tend to split into two camps. On one side sit the "simple" tools like Jekyll, Hugo, and 11ty that focus on the Jamstack fundamentals: pull data from a CMS or file, and render it into plain HTML templates. These tools offer a shallow learning curve, fast build times, and little to no JavaScript shipped to the client.

On the other side are the "dynamic" generators that bring component frameworks like React, Vue, or Svelte into the mix. They add interactivity, state management, and the ability to compose UI from smaller pieces, but they often introduce significant client-side JavaScript and a steeper learning curve.

The problem is that most projects don't fit neatly into one camp. A blog that starts life as simple Markdown may eventually need a carousel or a stateful form. A component-driven app may drown static pages in unnecessary JavaScript. The ideal solution lets you start in the simple camp and add complexity only when you need it.

Learning From A Build Pipeline Built From Scratch

The journey toward such a solution began with a challenge: build a personal site with no frameworks or build pipelines whatsoever. As a React devotee at the time, this meant rethinking what a static renderer actually needs to do. Looking back, three needs that often push people toward component frameworks turn out not to require JavaScript on the client at all:

  1. Modular UI fragments that accept data as parameters.
  2. Build-time data fetching to inject content into templates.
  3. Route generation from files or JSON content.

Template languages handle these quite well. In Pug, a mixin can act like a component and receive data via props:

// nav-mixins.pug
mixin NavBar(links)
    // pug's version of a for loop
    each link in links
        a(href=link.href) link.text

That mixin can then be reused across the site's template files:

// index.pug
// kinda like an ESM "import"
include nav-mixins.pug
html
  body
    +NavBar(navLinksPassedByJS)
    main
      h1 Welcome to my pug playground 🐶

Rendering the file with data yields a clean index.html:

const html = pug.render('/index.pug', { navLinksPassedByJS: [
    { href: '/', text: 'Home' },
    { href: '/adopt', text: 'Adopt a Pug' }
] })
// use the NodeJS filesystem helpers to write a file to our build
await writeFile('build/index.html', html)

This approach avoids both the need for bundlers and the cost of shipping a JavaScript runtime to the user. It keeps all the work on the build side and delivers a plain HTML file.

That said, the idea of pulling in a component framework precisely where it earns its keep remained appealing. And it turned out that even a "single-page app" feel — page transitions without a full browser reload — is achievable with plain web APIs. Intercept link clicks, use the fetch API to grab the new page's content, animate it with the Web Animations API, and update the URL bar with history.pushState(). Libraries like Swup exist precisely for this pattern.

What 11ty Added

Rebuilding the idea from scratch revealed gaps: no build-time CMS fetching, no per-page layouts, no image optimization — the list ran long. This prompted reaching for an existing SSG, and 11ty proved to be the right fit:

  • Build-time data fetching via .11tydata.js files.
  • Global data from the _data folder.
  • Hot reloading through Browsersync during development.
  • Support for HTML transforms.

Since 11ty is JavaScript throughout, it happily renders Pug pages to HTML routes and supports layout chaining. A single main.js file loaded in a global layout can handle all the link interception and page-transition logic described above:

// _includes/base-layout.html
<html>
<body>
  <!--load every page's content between some body tags-->
  {{ content }}
  <!--and apply the script tag just below this-->
  <script src="main.js"></script>
</body>
</html>

// random-blog-post.pug
---
layout: base-layout
---

article
  h2 Welcome to my blog
  p Have you heard the story of Darth Plagueis the Wise?

The most powerful feature, however, was the data cascade. This is the bread and butter of the Jamstack: fetch data at build time using Node from an API, a local YAML file, or even other routes on the site, then slot that data into a template via the same .render function concept — except now executed by 11ty behind the scenes.

Setting up a YAML file to list personal projects:

# _data/works.yaml
- title: Bits of Good Homepage
  hash: bog-homepage
  links:
    - href: https://bitsofgood.org
      text: Explore the live site
    - href: https://github.com/GTBitsOfGood/bog-web
      text: Scour the Svelt-ified codebase
  timeframe: May 2019 - present
  tags:
    - JAMstack
    - SvelteJS
- title: Dolphin Audio Visualizer
...

Then accessing that data from any template:

// home.pug
.project-carousel
  each work in works
    h3 #{title}
    p #{timeframe}
    each tag in tags
    ...

Compared to clientside rendering with something like create-react-app, this approach eliminates the need to ship API keys or large JSON blobs to the browser entirely.

When Templates Aren't Enough

Templates and animated page transitions got us surprisingly far. But there's a ceiling: modal dialogs, complex multi-step forms, or a component-driven design system like Material UI all demand real interactive state management. Plain DOM manipulation can handle these, but it lacks the debugging and testing conveniences that React, Vue, and Svelte provide.

The question becomes: can we build with straight HTML templates and progressively drop in framework components where needed? Yes — and the key is pairing 11ty with a build tool that handles the JavaScript side without demanding a full rewrite.

Vite as the Missing Bundler

11ty deliberately avoids JavaScript bundling. Adding React components the traditional way means setting up loaders for JSX, a Babel transform pipeline, SASS and CSS module imports, and live reloading. That's exactly the kind of configuration overhead that Vite was built to eliminate: point it at a directory, and it auto-converts .jsx, .vue, and .svelte files on the fly with hot module reloading.

To see how Vite works, create an empty project directory and install the basics:

npm init -y # Make a new package.json with defaults set
npm i vite react react-dom # Grab Vite + some dependencies to use React

An index.html entry point is minimal:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>
<body>
  <h1>Hello Vite! (wait is it pronounced "veet" or "vight"...)</h1>
  <div id="root"></div>
</body>
</html>

The only notable element is the root div, which will host a React component:

// TimesWeMispronouncedVite.jsx
import React from 'react'

export default function TimesWeMispronouncedVite() {
  const [count, setCount] = React.useState(0)
  return (
    <div>
      <p>I've said Vite wrong {count} times today</p>
      <button onClick={() => setCount(count + 1)}>Add one</button>
    </div>
  )
}

Loading that component onto the page takes just one script tag in the HTML:

<!DOCTYPE html>
...
<body>
  <h1>Hello Vite! (wait is it pronounced "veet" or "vight"...)</h1>
  <div id="root"></div>
  <!--Don't forget type="module"! This lets us use ES import syntax in the browser-->
  <script type="module">
    // path to our component. Note we still use .jsx here!
    import Component from './TimesWeMispronouncedVite.jsx';
    import React from 'react';
    import ReactDOM from 'react-dom';
    const componentRoot = document.getElementById('root');
    ReactDOM.render(React.createElement(Component), componentRoot);
  </script>
</body>
</html>

No pre-build step for JSX. No dist folder during development; Vite transforms on request, serving http://localhost:3000/about/ for an about.html file (note the required trailing slash).

Running 11ty and Vite Together

To combine the two, install 11ty as a dev dependency in the same project and set up a working directory:

npm i -D @11ty/eleventy # yes, it really is 11ty twice

From a clean slate, organize the project as follows:

  • Delete the test index.html.
  • Move the JSX component into a components/ folder.
  • Create a src/ directory for templates, adding a markdown file like blog-post.md.
# Hello world! It’s markdown here

The resulting structure:

src/
  blog-post.md
components/
  TimesWeMispronouncedVite.jsx

Running the build from the terminal produces output in the default _site directory:

npx eleventy --input=src

Here's where the handoff happens. Instead of using 11ty's built-in dev server, let Vite serve the _site directory that 11ty generates:

  1. 11ty builds markdown, Pug, Nunjucks, and other templates into _site.
  2. Vite watches that same directory, processing React components and style imports that 11ty left untouched.

Run both tools in watch mode — either in a single command or separate terminals for easier debugging:

(npx eleventy --input=src --watch) & npx vite _site

You can then visit http://localhost:3000/blog-post/ (trailing slash included) to view the processed markdown file.

Component Shortcodes for Partial Hydration

Shortcodes are 11ty's function-call syntax for injecting HTML into templates. The anatomy is straightforward:

{% react '/components/TimesWeMispronouncedVite.jsx' %}
  • {% … %} — the delimiter marking the shortcode's start and end.
  • react — the function name we'll register in 11ty's config.
  • '/components/TimesWeMispronouncedVite.jsx' — the first argument (the component path), with as many additional arguments as needed.

Register the shortcode in a project-level .eleventy.js config file:

// .eleventy.js, at the base of the project
module.exports = function(eleventyConfig) {
  eleventyConfig.addShortcode('react', function(componentPath) {
   // return any valid HTML to insert
   return `<div id="root">This is where we'll import ${componentPath}</div>`
  })

  return {
    dir: {
      // so we don't have to write `--input=src` in our terminal every time!
      input: 'src',
    }
  }
}

With a markdown template that invokes it:

# Super interesting programming tutorial

Writing paragraphs has been fun, but that's no way to learn. Time for an interactive code example!

{% react '/components/TimesWeMispronouncedVite.jsx' %}

The rendered page's HTML replaces the shortcode call with a placeholder for interactivity:

<h1>Super interesting programming tutorial</h1>

<p>Writing paragraphs has been fun, but that's no way to learn. Time for an interactive code example!</p>

<div id="root">This is where we'll import /components/TimesWeMispronouncedVite.jsx</div>

The shortcode itself outputs a script tag that Vite recognizes — generating the import statement from the componentPath argument:

// .eleventy.js
module.exports = function(eleventyConfig) {
  let idCounter = 0;
  // copy all our /components to the output directory
  // so Vite can find them. Very important step!
  eleventyConfig.addPassthroughCopy('components')

  eleventyConfig.addShortcode('react', function (componentPath) {
      // we'll use idCounter to generate unique IDs for each "root" div
      // this lets us use multiple components / shortcodes on the same page 👍
      idCounter += 1;
      const componentRootId = `component-root-${idCounter}`
      return `
  <div id="${componentRootId}"></div>
  <script type="module">
    // use JSON.stringify to
    // 1) wrap our componentPath in quotes
    // 2) strip any invalid characters. Probably a non-issue, but good to be cautious!
    import Component from ${JSON.stringify(componentPath)};
    import React from 'react';
    import ReactDOM from 'react-dom';
    const componentRoot = document.getElementById('${componentRootId}');
    ReactDOM.render(React.createElement(Component), componentRoot);
  </script>
      `
    })
  
  eleventyConfig.on('beforeBuild', function () {
    // reset the counter for each new build
    // otherwise, it'll count up higher and higher on every live reload
    idCounter = 0;
  })

  return {
    dir: {
      input: 'src',
    }
  }
}

Visiting the dev server now yields a working counter component:

<div id="component-root-1"></div>
<script type="module">
    import Component from './components/FancyLiveDemo.jsx';
    import React from 'react';
    import ReactDOM from 'react-dom';
    const componentRoot = document.getElementById('component-root-1');
    ReactDOM.render(React.createElement(Component), componentRoot);
</script>

This pattern is "islands architecture" in miniature: interactive component trees live only where needed, rather than wrapping the entire page. It pairs naturally with "partial hydration," which splits rendering into two steps:

  1. Static HTML output first — content is visible before any client-side processing.
  2. Hydration second — hooks and event listeners attach to that HTML, making buttons actually respond to clicks.

Full-site hydration means shipping a bundle that processes every DOM element — expensive and often unnecessary. The shortcode approach hydrates only sections that genuinely need state, keeping Lighthouse scores intact for the static majority.

From Proof of Concept to Plugin

This technique checks three core boxes:

  • Vite handles bundling and transforms for .jsx, .vue, and .svelte with zero configuration.
  • Shortcodes provide component-style HTML injection into any template.
  • That same mechanism enables on-demand interactive islands via partial hydration.

But production builds, scoped styles, and JSX-generated pages need further wiring. Those concerns are collected in Slinkity, a project that bundles this Vite-and-11ty workflow into a ready-to-use plugin.

Astro as the Alternative

Astro shares the same goal — plain HTML first, stateful components inserted where required. Its approach is even more flexible: React components inside Vue or Svelte components inside HTML templates. The cost, however, is a complete rewrite: a JSX-based template format, a new data pipeline, and early-stage rough edges.

For an existing 11ty site, the Vite-plus-shortcode path avoids that upheaval. Astro isn't a wrong choice for new projects, but the 11ty combination slots into an existing stack without touching a single template.

Closing Thoughts

The Slinkity experiment has held up well for my own projects — and evidently for a few readers too. The broader point is that the Jamstack ecosystem doesn't need to be a battle of rival architectures. Pick the tools that actually fit your content and your rendering needs, and you can keep the speed and simplicity of a static build without surrendering interactivity.

Where To Go Next

If you want to understand the ideas that inform this approach — from selective hydration to modern module patterns — these are the resources I kept coming back to:

Smashing Editorial