Switch to Bascik
From Eleventy
Eleventy is a static site generator centered on template languages and Markdown rendering; Bascik is a build tool for HTML components. Neither adds a client-side runtime by default, so both produce vanilla HTML (the Eleventy base blog itself ships one small module script for heading anchors). The main conceptual shift is that Bascik uses standard HTML component files resolved by tag name, rather than template languages like Nunjucks, Liquid, or Handlebars. Loops, conditionals, and filters move into small TypeScript helpers that build scripts call.
Mental Model Comparison
| Concept | Eleventy | Bascik |
|---|---|---|
| Component partial | _includes/nav.njk ({% include %}) | src/components/site-nav/site-nav.html (<site-nav>) |
| Layout inheritance | {% extends "base.njk" %} | <site-layout> with data-bascik-slot |
| Front matter / Global data | Front matter + _data/ directory | <script data-bascik-build> with Node.js fs |
| Scoped styles | Global CSS or build tools | Paired .css files (auto-scoped and deduplicated) |
| Client interactivity | Static JS files in output | <script> tags in components (auto-scoped instances) |
A Low-Risk First Step
Migrate a simple partial (like a header or footer) into a Bascik component:
- Create a project with
npm create bascik@latest my-site, or add Bascik to an existing folder withnpm install @bascik/bascik. - Create
src/components/site-footer/site-footer.html. - Add your footer HTML markup inside it. Replace template values such as
{{ eleventy.generator }}with fixed text. - Use
<site-footer />insidesrc/pages/index.html. - Run
npm run devto inspect the generated HTML.
Template Languages → HTML Component Files
Eleventy supports many template languages. In Bascik, all components are plain .html files. There is no template syntax, values are either hardcoded, injected as data-bascik-prop-* text props, passed as slot content, or generated by a <script data-bascik-build> block.
Those four mechanisms cover everything a blog template does, but expect the logic to move. Every {% for %}, {% if %}, filter, and loop.last check becomes a TypeScript function in src/lib/ that returns HTML, and a build script prints its result. In the base blog port, those helpers are most of the code.
Before (Eleventy) After (Bascik)
src/ src/components/
_includes/ site-layout/
blog/ src/pages/
my-post.md index.html
index.njk about/index.html
about.njk blog/index.html
blog/[slug]/index.html
src/lib/
posts.ts ← reads content/blog
render.ts ← loops and conditionals
content/blog/
my-post.md Markdown posts stay in a content/ folder outside src/pages/. One dynamic route template turns them into pages.
{% include %} → Bascik Component Tags
An Eleventy {% include 'nav.njk' %} that renders fixed markup becomes a self-contained Bascik component tag. The file name (hyphenated, without extension) is the tag name. An include that reads variables set by the calling template, such as the base blog's postslist.njk with postslist and postslistCounter, becomes a helper function instead: renderPostList(posts, counter) returns the list as HTML
{% include %} → Bascik Component Tags
An Eleventy {% include 'nav.njk' %} becomes a self-contained Bascik component tag. The file name (hyphenated, without extension) is the tag name.
{# src/_includes/nav.njk (Eleventy - before) #}
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
{# Used in a page with: #}
{% include "nav.njk" %} <!-- src/components/site-nav/site-nav.html (Bascik - after) -->
<nav class="nav">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
</nav>
<!-- Used in a page with: -->
<site-nav></site-nav> {% extends %} / Template Inheritance → Slot-based Layout Components
Eleventy's {% extends 'base.njk' %} and {% block content %} pattern maps to a Bascik layout component that uses data-bascik-slot for the page content area and named slots for additional regions like the page title.
{# src/_includes/base.njk (Eleventy - before) #}
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }} - Acme</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
{% include "nav.njk" %}
<main>{% block content %}{% endblock %}</main>
{% include "footer.njk" %}
</body>
</html> {# src/about.njk (Eleventy - before) #}
---
title: About
---
{% extends "base.njk" %}
{% block content %}
<h1>About</h1>
<p>We build things.</p>
{% endblock %} In Bascik, the layout component wraps only the repeated inner structure. The <html>, <head>, and <body> live in the page file; a component that tries to own them fails the build, because the page then has no body of its own. To avoid repeating the head on every page, put the shared tags in a head component that takes the title and description as props, such as <site-head data-bascik-prop-title="About">.
<!-- src/components/site-layout/site-layout.html (Bascik - after) -->
<site-nav></site-nav>
<main class="content">
<div data-bascik-slot></div>
</main>
<site-footer /> <!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Acme</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<site-layout>
<h1>About</h1>
<p>We build things.</p>
</site-layout>
</body>
</html> Front Matter Data → Hardcoded HTML or Build Scripts
Eleventy front matter (YAML between --- delimiters) injects variables into the template. Bascik has no front matter. Values that differ per page are either written directly into the HTML or supplied via data-bascik-prop-* props. Use a <script data-bascik-build> block when you want to read values from an external data file at build time.
{# src/about.njk (Eleventy - before) #}
---
title: About
description: Learn about Acme Corp.
---
<h1>{{ title }}</h1>
<meta name="description" content="{{ description }}" /> <!-- src/pages/about.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<title>About - Acme</title>
<meta name="description" content="Learn about Acme Corp." />
<link rel="stylesheet" href="/css/styles.css" />
</head>
<body>
<site-layout>
<h1>About</h1>
</site-layout>
</body>
</html> For many pages sharing a data-driven title pattern, extract the title into a data-bascik-prop-title prop on the layout component, or generate the pages from a dynamic route.
Markdown posts keep their front matter. Your helper parses it with gray-matter (npm install gray-matter) and should validate it, for example with a zod schema, so a missing date fails the build instead of producing a broken page.
Drafts
Eleventy's base blog drops draft: true posts from builds and keeps them in --serve. In Bascik, read process.env.BASCIK_BUILD in the helper that loads posts: it is "1" for bascik --build and "0" for the dev server.
// src/lib/posts.ts (excerpt)
const includeDrafts = process.env.BASCIK_BUILD !== '1';
const visible = posts.filter((post) => includeDrafts || post.data.draft !== true); Collections → <script data-bascik-build>
Eleventy's Collections API reads a directory of Markdown files and makes them available as a sorted array in templates. In Bascik, use a <script data-bascik-build> block that reads the same directory and returns a list of HTML links directly into the page. Sort by the parsed date, as Eleventy does, not by file name: file names such as firstpost, secondpost, thirdpost sort alphabetically into the wrong order.
{# src/blog/index.njk (Eleventy - before) #}
<ul>
{%- for post in collections.post | reverse -%}
<li>
<a href="{{ post.url }}">{{ post.data.title }}</a>
<time>{{ post.date | dateFilter }}</time>
</li>
{%- endfor -%}
</ul> <!-- src/pages/blog/index.html (Bascik - after) -->
<ul>
<script data-bascik-build>
import { readdir, readFile } from 'node:fs/promises';
import matter from 'gray-matter';
const escape = (s) => String(s).replaceAll('&', '&').replaceAll('<', '<').replaceAll('"', '"');
export default async function () {
const files = (await readdir('./content/blog')).filter(f => f.endsWith('.md'));
const posts = await Promise.all(files.map(async file => {
const { data } = matter(await readFile(`./content/blog/${file}`, 'utf8'));
return { slug: file.replace(/\.md$/, ''), title: data.title, date: new Date(data.date) };
}));
posts.sort((a, b) => b.date - a.date); // newest first, like `| reverse`
return posts.map(post => `<li>
<a href="/blog/${post.slug}/">${escape(post.title)}</a>
<time datetime="${post.date.toISOString().slice(0, 10)}">${post.date.toLocaleDateString('en-US', { month: 'long', year: 'numeric', timeZone: 'UTC' })}</time>
</li>`).join('\n');
}
</script>
</ul> In a real site, move the reading and sorting into src/lib/posts.ts so the home page, the archive, tag pages, and the feed share one list. Scripts that read content/ need scripts.cache.exclude and pipeline.watchPaths, because Bascik cannot see that folder as an import; see Invalidation Limits & Cache Exclusions.
Build scripts run first: A build script's output can itself contain Bascik component tags. They are resolved in the next pass, so you can output <post-card> tags from a build script and Bascik will expand them.
Tag Pages and Post Pages
Eleventy's pagination over collections becomes a dynamic route. src/pages/blog/[slug]/index.html produces /blog/<slug>/ for each post, and src/pages/tags/[tag]/index.html produces /tags/<tag>/. A route parameter cannot contain /, so a nested URL such as /blog/2026/post/ needs its own template, src/pages/blog/[year]/[slug]/index.html.
Passthrough File Copy → Automatic
In Eleventy, non-template files (CSS, images, fonts) must be explicitly added to the passthrough copy list in .eleventy.js. In Bascik, all non-.html files in src/pages/ are copied to dist/ automatically at build time with no configuration needed. Source files are never copied: dotfiles and .html, .md, .ts, .mjs, .cjs, .mts, .cts, .map, and test files are on a built-in deny-list (see directory). Images that live beside Markdown in content/ are outside src/pages/, so copy them with a pipeline.exec script that writes into the output directory.
src/pages/
index.html ← transpiled
about.html ← transpiled
css/
styles.css ← copied as-is
img/
hero.jpg ← copied as-is
fonts/
inter.woff2 ← copied as-is eleventy.config.js Plugins → Your Own Build Steps
Bascik replaces only one plugin category natively: CSS and JS scoping. Each of the base blog's other plugins becomes a small build step you write with a general-purpose library:
| Eleventy plugin or template | Bascik replacement |
|---|---|
sitemap.xml.njk | Built in. bascik --build writes sitemap.xml and robots.txt when a site URL is set. See Sitemap & robots.txt |
@11ty/eleventy-plugin-rss | A pipeline.exec script with phase: 'post' that writes the feed to the output directory, using a library such as feed |
@11ty/eleventy-plugin-syntaxhighlight | Highlight code in your Markdown render step with prismjs |
IdAttributePlugin, <heading-anchors> | Add heading ids and anchor links in your Markdown renderer, or keep the web component and declare it in components.external |
@11ty/eleventy-img | No built-in equivalent. Copy images with their width and height, or generate variants in your own exec script |
@11ty/eleventy-navigation | A navigation array in src/lib/ and a page-aware header that sets aria-current from BASCIK_PAGE_PATH |
HtmlBasePlugin and pathPrefix | The base option. Not exercised by the tutorial |
Before and After: Home Page
The base blog's home page shows the three newest posts and a count of the rest.
{# content/index.njk (Eleventy - before) #}
---js
const numberOfLatestPostsToShow = 3;
---
{% set postsCount = collections.posts | length %}
{% set latestPostsCount = postsCount | min(numberOfLatestPostsToShow) %}
<h1>Latest {{ latestPostsCount }} Post{% if latestPostsCount != 1 %}s{% endif %}</h1>
{% set postslist = collections.posts | head(-1 * numberOfLatestPostsToShow) %}
{% set postslistCounter = postsCount %}
{% include "postslist.njk" %}
{% set morePosts = postsCount - numberOfLatestPostsToShow %}
{% if morePosts > 0 %}
<p>{{ morePosts }} more post{% if morePosts != 1 %}s{% endif %} can be found in <a href="blog.njk">the archive</a>.</p>
{% endif %} The template logic becomes one helper. loadPosts returns posts oldest first, sorted by date, with drafts removed from builds:
// src/lib/render.ts (excerpt)
const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? '' : 's'}`;
export function renderHome(posts: Post[], latest = 3): string {
const shown = posts.slice(-latest);
const more = posts.length - latest;
const parts = [`<h1>Latest ${plural(shown.length, 'Post')}</h1>`, renderPostList(shown, posts.length)];
if (more > 0) parts.push(`<p>${plural(more, 'more post')} can be found in <a href="/blog/">the archive</a>.</p>`);
return parts.join('\n');
} <!-- src/pages/index.html (Bascik - after) -->
<!DOCTYPE html>
<html lang="en">
<head>
<site-head data-bascik-prop-title="Home - Acme Blog"></site-head>
</head>
<body>
<site-header />
<main id="main">
<script data-bascik-build>
import { loadPosts } from '@/lib/posts.ts';
import { renderHome } from '@/lib/render.ts';
export default async function () {
return renderHome(await loadPosts());
}
</script>
</main>
<site-footer />
</body>
</html> Post URLs keep Eleventy's trailing slash (/blog/firstpost/) because the post template is src/pages/blog/[slug]/index.html.