My first blog

Problem Statement:
The platform lacks a content marketing channel. A blog enables each tenant to publish SEO-optimized, multilingual articles authored in Strapi CMS and rendered in the SvelteKit app — driving organic traffic, establishing authority, and supporting the existing 13-locale i18n pipeline.
Success Criteria
- Blog posts authored in Strapi are rendered on the frontend within the tenant's branding
- Posts auto-translate to all 13 locales via the existing webhook pipeline (no changes to translation infra)
- Blog pages rank in search engines (structured data, sitemap, hreflang, og:article)
- Tenants without the
blogfeature flag see no blog routes (404)
User Stories
- As a content author, I want to write blog posts in Strapi with rich markdown, images, and video embeds so I can publish content without touching code
- As a site visitor, I want to browse blog posts filtered by category so I can find relevant content
- As a site visitor, I want a table of contents on long posts so I can navigate quickly
- As a site visitor, I want to read posts in my language, with automatic translation from English
- As a site visitor, I want to share posts on social media
- As a site visitor, I want to discover related posts and learn about the author
- As an SEO manager, I want each post to have proper meta tags, structured data, and sitemap entries
Functional Requirements
Must Have (MVP)
- Strapi content types:
blog-posts,blog-authors,blog-categories— all with tenant, locale, inherit fields - Blog listing page (
/blog) — paginated grid of posts, category filter via query param - Post detail page (
/blog/[slug]) — full markdown rendering with ToC, author card, related posts, social share - Category page (
/blog/category/[slug]) — filtered listing reusing the same listing components - Author page (
/blog/author/[slug]) — author bio + their posts - Table of Contents — auto-generated from h2/h3 headings, sticky sidebar on desktop, collapsible on mobile, active heading highlight via Intersection Observer
- YouTube/Vimeo embeds — bare URLs in markdown auto-convert to responsive iframes (youtube-nocookie.com for privacy)
- Read time — calculated server-side from word count (Math.ceil(words / 200))
- Social share buttons — X, Facebook, LinkedIn, email, copy-link (Web Share API where available)
- Related posts — same category, exclude current, limit 3, sorted by publishedAt desc
- RSS feed (
/blog/rss.xml) — RSS 2.0, locale via query param - SEO — SeoComponent per post, og:article tags, BlogPosting JSON-LD schema, canonical URLs
- Sitemap — dynamic blog post/category/author URLs added to existing sitemap with hreflang
- Feature flag —
blog: booleanin TenantConfig.features, layout guard returns 404 when disabled - i18n — Paraglide message keys for all UI strings (nav, labels, pagination), Strapi content auto-translated via existing webhook
- Pagination — page-based using Strapi's native pagination (9 posts per page)
- Featured post — boolean
featuredflag on blog-posts; featured post displayed as a hero card at the top of the/bloglisting page (larger card, full-width, above the grid)
Nice to Have (Post-MVP)
- Full-text search across blog posts
- Post series / multi-part articles
- Reading progress bar
- Comments (likely via third-party embed)
- Newsletter signup CTA within posts
- View count / analytics integration
Technical Requirements
Data Model (Strapi Content Types)
blog-posts (api::blog-post.blog-post):
| Field | Type | Localized | Notes |
|---|---|---|---|
| title | Text (required) | Yes | Matched by translation config |
| slug | UID (from title) | No | Excluded from translation |
| excerpt | Text | Yes | For cards + meta description |
| body | Rich Text (Markdown) | Yes | Rendered via marked |
| featuredImage | Media (single) | No | URL + alternativeText |
| category | Relation (manyToOne -> blog-category) | No | |
| author | Relation (manyToOne -> blog-author) | No | |
| featured | Boolean (default false) | No | Featured post shown as hero card on listing |
| seo | Component (shared.seo) | Yes | Reuse existing SeoComponent |
| tenant | Text | No | Standard tenant filter |
| inherit | Boolean (default true) | No | Auto-translation flag |
blog-authors (api::blog-author.blog-author):
| Field | Type | Localized | Notes |
|---|---|---|---|
| name | Text (required) | Yes | |
| slug | UID (from name) | No | |
| bio | Rich Text (Markdown) | Yes | |
| avatar | Media (single) | No | |
| socialLinks | JSON | No | { twitter?, linkedin?, website? } |
| tenant | Text | No | |
| inherit | Boolean (default true) | No |
blog-categories (api::blog-category.blog-category):
| Field | Type | Localized | Notes |
|---|---|---|---|
| name | Text (required) | Yes | |
| slug | UID (from name) | No | |
| description | Text | Yes | |
| color | Text | No | Hex color for UI badges |
| tenant | Text | No | |
| inherit | Boolean (default true) | No |
TypeScript Interfaces
See src/lib/services/strapi-types.ts for BlogPost, BlogAuthor, BlogCategory.
Routes
All under src/routes/(marketing)/blog/:
| Route | Server Load | Purpose |
|---|---|---|
blog/+layout.server.ts |
Feature guard + load categories | Gate + nav data |
blog/+page.server.ts |
Paginated posts, optional ?category= filter |
Listing |
blog/[slug]/+page.server.ts |
Post by slug + related posts + read time calc | Detail |
blog/category/[slug]/+page.server.ts |
Posts filtered by category | Category listing |
blog/author/[slug]/+page.server.ts |
Author + their posts | Author page |
blog/rss.xml/+server.ts |
RSS 2.0 XML, ?locale= param |
Feed |
UI Components
All in src/lib/components/blog/:
| Component | Props | Notes |
|---|---|---|
BlogPostCard.svelte |
post: BlogPost |
Card with image, category badge, title, excerpt, author, date, read time |
BlogPostContent.svelte |
body: string |
Markdown to HTML via marked with video embed extension + heading IDs |
TableOfContents.svelte |
entries: TocEntry[] |
Sticky sidebar, Intersection Observer for active state, mobile collapsible |
AuthorCard.svelte |
author: BlogAuthor |
Avatar, name, bio, social links |
SocialShare.svelte |
url: string, title: string |
Share buttons with Web Share API fallback |
RelatedPosts.svelte |
posts: BlogPost[] |
Grid of BlogPostCard |
BlogPagination.svelte |
pagination: StrapiPagination, basePath: string |
Prev/next + page numbers |
Security & Auth
- Blog is public (no auth required) — marketing content
- Strapi API read access via existing
STRAPI_JWTtoken - Video embeds use
youtube-nocookie.comfor GDPR compliance
Edge Cases & Error Handling
- Post not found: 404 via SvelteKit
error() - No posts in category: Empty state with
blog_no_postsmessage - Missing featured image: Card renders without image
- Translation not available:
loadContentWithFallback()falls back to English - RTL languages: Layout respects
dir="rtl"(ar, he, ur)
E2E Test Plan
Test files live in tests/blog/. Follow existing patterns: parameterized across viewports, Axe for a11y, networkidle waits for Strapi content.
Critical Path Tests
- Blog listing loads —
/blogrenders a grid of post cards with title, excerpt, category badge, author, date, and read time - Pagination works —
/blog?page=2shows the second page of results; prev/next buttons navigate correctly; page 1 has no "Previous" button - Category filter — clicking a category chip filters posts; URL updates to
?category=<slug>; "All" chip clears the filter - Post detail renders —
/blog/[slug]shows title, featured image, formatted body, author card, read time, and published date - Table of Contents — post detail page generates ToC from h2/h3 headings; clicking a ToC entry scrolls to the heading
- Related posts — post detail shows up to 3 related posts from the same category (excluding current)
- Category page —
/blog/category/[slug]shows category name, description, and only posts from that category - Author page —
/blog/author/[slug]shows author bio, avatar, social links, and their posts - Social share buttons — share buttons are visible on post detail; "Copy link" copies the URL and shows confirmation text
- RSS feed —
GET /blog/rss.xmlreturns valid XML withContent-Type: application/rss+xmland contains post entries - Navigation — blog link in the site menu navigates to
/blog; breadcrumbs show correct hierarchy - Featured post —
/blogdisplays the featured post as a larger hero card above the regular grid
Edge Case & Error Tests
- Post not found —
/blog/nonexistent-slugreturns a 404 page - Empty category — a category with no posts shows the
blog_no_postsempty state message - Missing featured image — post card renders gracefully without an image (no broken
<img>) - Missing author/category — post without author or category renders without those elements (no crash)
- Feature flag disabled — when
blog: false,/blogreturns 404
Cross-Cutting Tests
- i18n: locale switch — navigate to
/fr/blog, verify UI strings (page title, "Read more", pagination labels) are in French; post content falls back to English if untranslated - i18n: RTL layout — navigate to
/ar/blogand/ar/blog/[slug], verifydir="rtl"is set and layout renders correctly (ToC on correct side) - SEO: meta tags — post detail page has
og:type=article,og:title,og:description,article:published_time, and canonical link - SEO: JSON-LD — post detail page contains a
<script type="application/ld+json">with@type: BlogPosting, headline, author, datePublished - SEO: sitemap —
/sitemap.xmlincludes blog post URLs with hreflang alternates - Accessibility — run Axe audits on
/blog,/blog/[slug],/blog/author/[slug]at both mobile and desktop viewports (follows existing a11y test pattern) - Keyboard navigation — ToC entries and pagination buttons are focusable and activatable via keyboard; focus indicators are visible
Out of Scope
- Blog post comments
- Full-text search
- Newsletter integration within posts
- Analytics/view counts
- Custom post templates per tenant
Ludo is an educator and entrepreneur in the technology field, driven by a simple belief: quality education should be accessible to everyone.