A high-performance, SEO-optimized SvelteKit boilerplate for the Enodo Butterfly CMS. Built with simplicity and best practices in mind.
At Enodo, we believe in simplification ("enodo" = to untie, to simplify). This boilerplate delivers a simple yet powerful foundation for creating SEO-optimized blogs. All optimizations are included out of the box while maintaining clean, maintainable code.
The design is intentionally neutral to give you full creative freedom, while the semantic structure is ultra-optimized for accessibility and search engines.
Image and Picture enable proper lazy loading of images from the Butterfly API, with easy support for responsive images with multiple sizes. Image uses srcset and sizes attributes, while Picture uses <source> elements for more advanced responsive scenariosview-transition APIschema-dts/search route with keyboard navigation supportapp.version, app.platform, app.env, page.index, page.query, content.type, content.id, content.group, content.flags, content.tagsdata.scope: Contains a Butterfly.ApiResponse object for the current page context (category, post, author, tag, etc.)data.feeds: Contains promises or records of Butterfly.ApiResponse objects. The <Feed> component automatically handles lazy loading on the client side via Svelte streams, or direct SSR rendering when appropriate, making content loading seamless and performantdata.meta: Contains SEO metadata (url, title, description, robots) used for meta tags, Open Graph, and canonical URLsdata.layer: Contains analytics and tracking data (page.index, page.query, content.type, content.id, content.group, content.tags, content.flags) automatically pushed to Google Tag Manager data layerlib/api.ts for easy configurationFork this repository from enodo-io/sveltekit-butterfly-boilerplate
Install dependencies:
npm install
npm run setup
This interactive wizard will guide you through:
npm run dev
Your site will be available at http://localhost:5173
For deployment, see the official SvelteKit adapter documentation. This boilerplate currently uses @sveltejs/adapter-node but can be switched to any adapter (Vercel, Netlify, Cloudflare, etc.).
To build for production:
npm run build
npm run preview
The setup script creates a .env file with the following variables:
| Variable | Required | Description |
|---|---|---|
PUBLIC_BASE_URL |
Yes | Your website's public URL (e.g., https://mywebsite.com) |
PUBLIC_API_URL |
Yes | Butterfly API domain (from your Butterfly settings) |
PUBLIC_API_KEY |
Yes | Butterfly API key (from your Butterfly settings) |
PUBLIC_MEDIA_URL |
Yes | Butterfly media domain (for static assets) |
PUBLIC_STATIC_PAGES |
No | JSON object mapping page slugs to IDs (e.g., {"about":1,"legal":2}) |
PUBLIC_INDEXABLE |
Yes | Set to false for staging/dev to prevent indexing. Defaults to true if not set |
PUBLIC_LOCALE |
Yes | Locale code for your site (e.g., fr_FR, en_US, es_ES) |
PUBLIC_LANGUAGE |
Yes | Language code (e.g., fr, en, es) - auto-extracted from locale by default |
PUBLIC_GTM_ID |
No | Google Tag Manager container ID (e.g., GTM-XXXXXXX) |
This boilerplate expects the following structure in your Butterfly CMS:
tags must existpage for static pages (Legal, About, etc.)featured must existCustomize static page URLs via the PUBLIC_STATIC_PAGES environment variable.
Colors are defined as CSS custom properties in src/assets/styles/tailwind.css inside the @theme block:
/* src/assets/styles/tailwind.css */
@theme {
--color-light: #fff; /* background */
--color-light-900: #181a34; /* text */
--color-flash: #4bdc9f; /* primary accent */
--color-flash-600: #2ca87a;
--color-flash-700: #1a8f5f;
--color-error: #dc2626;
/* ... */
}
All tokens become Tailwind utilities automatically: bg-flash, text-light-900, border-error, text-flash-600, etc.
The boilerplate uses two color families by default:
light: neutral palette (background, text, borders) — scales from 025 (near-white) to 900 (near-black)flash: primary accent color — scales from 050 to 700You can rename these families or add as many as you need (brand, secondary, success, warning…).
Two things to configure:
1. Import the font package in src/assets/styles/main.scss:
/* src/assets/styles/main.scss */
@use '@fontsource/inter'; /* replace or add */
Install fonts via:
npm install -D @fontsource/{font-name}
# or for variable fonts:
npm install -D @fontsource-variable/{font-name}
2. Update the font token in src/assets/styles/tailwind.css:
/* src/assets/styles/tailwind.css */
@theme {
--font-sans: 'Inter', sans-serif;
}
The boilerplate defines three font roles:
| Token | Tailwind class | Default | Usage |
|---|---|---|---|
--font-brand |
font-brand |
Poppins | Headings, branding |
--font-sans |
font-sans |
Open Sans Variable | Body copy |
--font-mono |
font-mono |
Fira Mono | Code, monospace |
Language and locale are configured via environment variables:
PUBLIC_LOCALE (e.g., fr_FR, en_US, es_ES, de_DE)PUBLIC_LANGUAGE (e.g., fr, en, es) - auto-extracted from locale by defaultThese variables are used throughout the application for:
lang attribute in src/app.htmlog:locale meta tagsImportant: After setting the environment variables, you must also:
src/lib/httpErrors.tsExample translations needed:
src/lib/httpErrors.tsReplace default assets:
npm run generate:favicons to regenerate all favicon-*.png and favicon.ico from static/favicon.svglogo-*.jpg and logo-*.png files in the static/ foldersrc/assets/images/ (recommended size: 1366x768px)Update static/robots.txt with your actual site URL:
Sitemap: https://www.yoursite.com/sitemaps/index.xml
Sitemap: https://www.yoursite.com/sitemaps/news.xml
/File: src/routes/+page.svelte
featured to display a hero article/[path]File: src/routes/[...path]/+page.svelte
Lists all posts in the corresponding category with pagination.
/articlesFile: src/routes/articles/+page.svelte
Displays all published posts across all categories.
/searchFile: src/routes/search/+page.svelte
Full-text search functionality with keyboard navigation.
/authorsFile: src/routes/authors/+page.svelte
Lists all site authors with their profile information.
/authors/[slug]File: src/routes/authors/[slug]/+page.svelte
Displays author profile and their latest posts.
/tagsFile: src/routes/tags/+page.svelte
Lists all tags from the tags taxonomy.
/tags/[slug]File: src/routes/tags/[slug]/+page.svelte
Displays all posts tagged with the specified tag.
/rss.htmlFile: src/routes/rss.html/+page.svelte
Syndication directory listing all available RSS feeds.
/[slug]-[id].htmlFile: src/routes/[slug=post]/+page.svelte
/[env-defined-slug].htmlFile: src/routes/[slug=page].html/+page.svelte
Example: /about.html, /legal.html (configured via PUBLIC_STATIC_PAGES)
Important: Remember to set proper canonical URLs in Butterfly for these pages.
All feeds are available at /[format=feed]/ routes:
/rss/index.xmlLatest posts from all categories
/rss/sections/[path].xmlPosts from a specific category
/rss/authors/[slug].xmlPosts by a specific author
/rss/tags/[id].xmlPosts tagged with a specific tag
All sitemaps are generated automatically and available at /sitemaps/:
/sitemaps/index.xml - Master sitemap/sitemaps/news.xml - Posts published in the last 48 hours/sitemaps/sections.xml - All category pages/sitemaps/tags.xml - All tag pages/sitemaps/authors.xml - All author pages/sitemaps/pages.xml - Static pages (post type page and hardcoded pages)/sitemaps/posts.xml - All article pagesThe $lib directory contains reusable utility functions and helpers for the application.
File: lib/api.ts
Pre-configured Butterfly API client instance using environment variables. Used throughout the application to interact with the Butterfly CMS API.
Usage:
import api from '$lib/api';
// Using path
const posts = await api.get({ path: '/v1/posts' });
const next = await api.get({ path: posts.links.next });
// Using endpoint and ID
const post = await api.get({ endpoint: 'posts', id: 1 });
// With query parameters
const search = await api.get({
endpoint: 'posts',
query: { filter: { query: 'search query' } },
});
// With custom fetch options
const data = await client.get({
endpoint: 'posts',
signal: abortController.signal,
intercept: (response) => console.log(response),
});
Client methods:
client.get<T>(options) - Fetch data from Butterfly APIpath - Full API path (e.g., /v1/posts)endpoint - Endpoint name (e.g., posts, authors)id - Resource IDquery - Query parameters objectsignal - AbortSignal for cancellationintercept - Function to intercept responsefetch - Custom fetch functionFile: lib/getMediaUrl.ts
Generates media URLs from Butterfly media objects. Automatically injects the media domain from environment variables.
Usage:
import { getMediaUrl } from '$lib/getMediaUrl';
// Basic usage with format and width
const url = getMediaUrl({
media: butterflyMedia,
format: 'thumb',
width: 800,
});
// With custom extension
const jpgUrl = getMediaUrl({
media: imageMedia,
format: 'cover',
width: 1200,
ext: 'jpg',
});
// Video with definition (required for .mp4)
const videoUrl = getMediaUrl({
media: videoMedia,
format: 'source',
ext: 'mp4',
definition: 'hd',
});
// Custom slug
const customUrl = getMediaUrl({
media: butterflyMedia,
format: 'square',
width: 400,
slug: 'avatar',
});
Parameters:
media - Butterfly media object (required)format - Image/video format: 'default', 'source', 'thumb', 'square', 'cover', 'stories' (default: 'default')width - Image width in pixels (for images)ext - File extension (e.g., 'jpg', 'png', 'webp', png, 'mp4', 'mp3')slug - URL slug (default: 'media')definition - Video definition (required for .mp4 or .mp3 formats: 'sd', 'hd'.)File: lib/httpErrors.ts
User-friendly error messages for common HTTP status codes (400, 401, 403, 404, 410, 422, 429, 500, 503). Used in error pages throughout the application.
import httpErrors from '$lib/httpErrors';
const message = httpErrors[404]; // "Oops! The page you're looking for wandered off 🕵️♂️"
File: lib/breakpoints.js
Responsive breakpoint values for media queries. Auto-generated during build.
import { breakpoints } from '$lib/breakpoints';
// { sm: 600, md: 1008, lg: 1280 }
Located in lib/JsonLD/, these utilities generate structured data (Schema.org) for SEO:
Article.ts: Generates Article/NewsArticle schemaBreadcrumbList.ts: Generates breadcrumb navigation schemaOrganization.ts: Generates publisher organization schemaProfilePage.ts: Generates author profile schemaWebPage.ts: Generates standard web page schemaWebSite.ts: Generates website schema with search actionFAQPage.ts: Generates standard FAQ page schemaMain entry point:
import { generateJsonLd } from '$lib/JsonLD';
const schemas = generateJsonLd(pageData, ['WebPage', 'Article', 'BreadcrumbList']);
All these utilities are available throughout the app via the $lib alias.
Layout/Header.svelteMain site header with navigation menu.
Layout/Footer.svelteSite footer with links and metadata.
Layout/Categories.svelteDisplays the category hierarchy.
Dialog.svelteNative <dialog>-based overlay with clickable backdrop (closes on direct backdrop click) and Escape key support.
Props:
open: booleanonClose?: () => voidcontent: SnippetCard.sveltePost card component for displaying articles.
Props:
post: The Butterfly post data (required)included: Array of included resources from the API response (required)width: Image width (default: 380)heading: Heading level 'h1' to 'h4' (default: 'h3')format: Image format 'default', 'source', 'thumb', 'square', 'cover', 'stories' (default: 'thumb')lazyload: Enable lazy loading (default: true)thumbnail: Show thumbnail image (default: true)resume: Show post resume/description (default: true)author: Show author info (default: true)date: Show date info (default: true)widths: Array of image widths for responsive srcset (default: [320, 480, 540])sizes: Sizes attribute for responsive imagesUsage:
<Card post={article} included={response.included} heading="h2" format="cover" />
Customization:
:global(.card) in route styles or edit src/assets/styles/_cards.scssPost/Body.svelteRenders a Butterfly post body (headings, paragraphs, lists, media, embeds, etc.).
.post--[element] selectors in src/assets/styles/post.css (e.g., .post--list, .post--quote, .post--youtube).Feed.svelteWrapper component that displays post cards with loading skeleton states.
Props:
feed: Promise or Butterfly API response containing posts (required)length: Number of skeleton cards to show while loading (default: 6)width: Image width for cards (default: 380)format: Image format (default: 'thumb')heading: Heading level (default: 'h3')lazyloadAfter: Index after which to lazy load images (default: 0)thumbnail: Show thumbnail (default: true)resume: Show resume (default: true)author: Show author (default: true)date: Show date (default: true)widths: Image widths arraysizes: Sizes attributeUsage:
<Feed feed={posts} length={9} lazyloadAfter={3} />
Breadcrumb.svelteDisplays a semantic breadcrumb trail based on page.data JSON-LD.
Props: None (reads from page.data)
Usage:
<Breadcrumb />
Automatically generates from BreadcrumbList schema in page data.
Pagination.svelteHandles pagination UI with optional infinite scroll support.
Props:
current: Current page number (required)max: Maximum number of pages (required)url: Function that generates URL for a page number (default: (page) => \/?page=${page}``)pad: Number of pages to show before/after current (default: 2)label: Button label for "load more" (default: 'Load more')controls: ARIA controls IDonload: Async function to load next page (returns Promise<boolean>)next: Whether there is a next page availableinfiniteScroll: Enable infinite scroll modeUsage:
<Pagination
current={page}
max={totalPages}
url={(p) => `/articles?page=${p}`}
onload={loadNextPage}
next={hasMore}
/>
Image.svelteEnhanced <img> component with lazy loading and responsive images.
Props:
media: Butterfly media objectlazyload: Enable lazy loading (default: true)format: Image format 'default', 'source', 'thumb', 'square', 'cover', 'stories' (default: 'default')width: Base image width (default: 380)widths: Array of widths for srcset (default: [320, 480, 768, 990])sizes: Sizes attribute (default: '100vw')alt: Alt text<img> attributesUsage:
<Image
media={article.media}
format="thumb"
width={540}
widths={[400, 800, 1200]}
sizes="(max-width: 768px) 100vw, 800px"
alt={article.title}
/>
Automatically generates 2x versions and falls back to the default thumbnail image from src/assets/images/thumb.jpg if no media.
Picture.svelteAdvanced picture component with multiple sources for different breakpoints.
Props:
media: Butterfly media objectlazyload: Enable lazy loading (default: true)format: Image format (default: 'default')width: Base image width (default: 380)srcset: Array of { query: string, width: number } objects for responsive sourcesalt: Alt textUsage:
<Picture
media={article.media}
srcset={[
{ query: '(max-width: 768px)', width: 400 },
{ query: '(min-width: 769px)', width: 800 },
]}
alt="Description"
/>
Post/Elements/Video.svelteMinimal <video> wrapper with HD/SD sources and poster, no lazyload or custom player. Automatically picks HD/SD depending on network conditions (Network Information API when available).
The boilerplate includes typed JSON-LD schemas using schema-dts:
src/lib/JsonLD/src/lib/JsonLD/index.ts+page.svelteGTM is integrated via the GTM.svelte component and loads client-side when PUBLIC_GTM_ID is set. The project ships an importable GTM container template at scripts/GTM.json that wires every standard event and data-layer variable to Google Analytics 4 — no manual tag creation required.
Create or pick a GTM container Go to tagmanager.google.com, create an account + container (Target platform: Web) if you don't already have one.
Import the template
In your container go to Admin → Import Container and upload scripts/GTM.json. Settings:
Click Confirm. The account/container IDs inside the file are placeholders ("0", GTM-XXXXXXX); GTM reassigns them to your workspace automatically.
Set your GA4 Measurement ID
In the container: Variables → User-Defined Variables → GA Tracking ID. Replace the G-XXXXXXXXXX placeholder with the Measurement ID from GA4 (Admin → Data Streams → Web → Measurement ID).
Publish the container
Click Submit, give the version a name, Publish. Copy the container ID (GTM-XXXXXXX) from the top-right of the GTM UI.
Wire it into the app
Add to .env:
PUBLIC_GTM_ID=GTM-XXXXXXX
Restart the dev server. GTM loads on every client navigation.
Tags (4):
GA Tag — base Google Analytics 4 configurationGA Page View — forwards pageview events to GA4 as page_viewGA Infinite Scroll — forwards infinite_scroll eventsGA Load More Click — forwards load_more_click eventsTriggers (3): custom-event triggers matching the three dataLayer event names emitted by the app.
Variables (10): one Data Layer Variable per field in App.PageData.layer — each is forwarded to GA4 as a snake_case event parameter (content_id, page_index, app_version, …).
Every navigation pushes a pageview event with these fields (defined in App.PageData.layer):
Page scope:
page.index: current pagination pagepage.query: search query (on /search)Content scope:
content.type: home | articles | post | page | author | category | tag | searchcontent.id: the resource id (stringified for consistent GTM matching)content.group: category / taxonomy groupcontent.tags: array of tag labelscontent.flags: array of flag stringsApp scope (pushed once on init):
app.version: package version + git commitapp.platform: web or pwaapp.env: development or productionThree standard events ship with the container:
| Event | Fires | Payload |
|---|---|---|
pageview |
Every navigation + infinite-scroll load | full layer object (all fields above) |
load_more_click |
User clicks "Load more" in <Pagination> |
layer + page.index of the page about to be loaded |
infinite_scroll |
Pagination button auto-loads via inview | layer + page.index of the page about to be loaded |
Intent events (load_more_click / infinite_scroll) fire before the API call; the paired pageview fires after the load succeeds. This lets analytics derive the load-failure rate as intent − pageview for the same page.index.
All pushes go through the $lib/dataLayer helper — never call window.dataLayer.push directly (the helper normalises content.id to a string and no-ops on SSR):
import { track, push } from '$lib/dataLayer';
// Named event with payload
track('click_cta', { cta_id: 'hero_primary', cta_label: 'Subscribe' });
// Arbitrary payload (no `event` field)
push({ 'app.version': '1.2.3' });
The imported container forwards 10 custom event parameters to GA4. To surface them in reports, register each one as a custom dimension in GA4:
| Event parameter | Suggested dimension name |
|---|---|
page_index |
Page Index |
page_query |
Page Query |
content_id |
Content ID |
content_type |
Content Type |
content_group |
Content Group |
content_tags |
Content Tags |
content_flags |
Content Flags |
app_version |
App Version |
app_env |
App Environment |
app_platform |
App Platform |
Notes:
infinite_scroll events per content_type?"), use Explore → Free form with the Event name dimension + your custom dimensions.If you add new fields to App.PageData.layer, remember to:
scripts/GTM.json to keep the template in sync).# Install the font
npm install -D @fontsource/inter
/* src/assets/styles/main.scss — add the @use */
@use '@fontsource/inter';
/* src/assets/styles/tailwind.css — update the token */
@theme {
--font-sans: 'Inter', sans-serif;
}
Edit the @theme block in src/assets/styles/tailwind.css:
@theme {
--color-flash: #3b82f6; /* your primary accent */
--color-flash-050: #eff6ff;
--color-flash-100: #dbeafe;
--color-flash-600: #2563eb;
--color-flash-700: #1d4ed8;
}
Or add entirely new color families:
@theme {
--color-brand: #7c3aed;
--color-brand-600: #6d28d9;
--color-success: #10b981;
--color-warning: #f59e0b;
}
These become Tailwind utilities instantly: bg-brand, text-success, border-warning, etc.
# Setup environment
npm run setup
# Development
npm run dev # Start dev server
npm run check # Type check
npm run check:watch # Watch type checking
# Build
npm run build # Production build
npm run preview # Preview production build
# Testing
npm run test # Run all tests
npm run test:unit # Unit tests
npm run test:e2e # E2E tests with Playwright
# Assets
npm run generate:favicons # Regenerate all favicons from favicon.svg
# Code Quality
npm run lint # Lint code
npm run format # Format code
This boilerplate ships first-class Claude Code tooling so an LLM can contribute without re-learning the conventions each session.
CLAUDE.md at the repo root — the entry point. Stack, architectural invariants (PageData contract, streaming pattern, sitemap registration rule, single-language design), design-system rules, file layout, and per-task skill pointers..claude/skills/ — 24 focused skills covering every recurring task: Butterfly API patterns, JSON-LD schemas, image/picture usage, feed streaming, Svelte 5 Runes, layout/server rules, HTTP errors, cache control, typography/spacing/z-index/fonts tokens, SEO links, cards, HTML-first UI, CSS-vs-Tailwind decision rules, taxonomies, translation, testing, post body customisation, GTM events, and more..claude/commands/ — 10 slash commands for scaffolding and audits: /new-route, /new-taxonomy, /new-jsonld, /new-feed, /translate-to, /change-palette, /audit-seo, /check-butterfly-env, /add-static-page, /svelte4-to-5..claude/agents/ — 4 read-only specialist subagents: butterfly-explorer (API types), seo-auditor, a11y-auditor, perf-reviewer..claude/settings.json hooks — auto-runs Prettier + ESLint on every edited file, surfaces skill-rule reminders for .svelte/.css/+page.server.ts edits, and runs svelte-check + Vitest at the end of each response.Every skill and command is scoped and short — the LLM is expected to read the relevant one before acting, not to rely on training recall. If you fork this boilerplate and change a convention, update the corresponding skill; the hooks and commands reference them by name.
For non-AI contributors, the same skills double as a concise style guide for the codebase.
This is the official Enodo boilerplate. For issues or questions:
See CONTRIBUTING.md for contribution guidelines.
MIT © Enodo