Last Updated: 2026-04-02 Status: Active
This document maps the end-to-end user journeys for the Blog Engine, covering both the admin authoring experience (Seller Dashboard) and the public consumption experience (customer websites via SDK).
1. Context & Goal
The Blog Engine enables Vlozi tenants (SaaS founders, agencies, e-commerce businesses) to create, manage, and publish blog content directly from their dashboard — then embed that content into any customer-facing website using a lightweight React SDK (@vlozi/blog).
The system separates two distinct user flows through a single API gateway:
- Admin Door — JWT-authenticated CRUD for content authors
- Public Door — API-key-authenticated read-only access for website visitors
System Flow Overview
2. Actors
| Actor | Description |
|---|---|
| Workspace Owner | Primary decision-maker. Creates content, manages publication lifecycle, configures SEO, and sets up the SDK integration on their website. |
| Team Member | Collaborator with scoped permissions. May author posts but might lack publish or delete permissions depending on their assigned role. |
| Website Visitor | End-user browsing the tenant's customer-facing website. Consumes blog content via the embedded SDK components. |
| API Gateway | Central routing layer. Validates JWT (admin) or API key (public) and injects tenant context headers before forwarding to the blog service. |
| Blog Service | Cloudflare Worker handling all CRUD operations, permission enforcement, and TipTap-to-HTML rendering for the public API. |
3. Core User Journeys
Journey A: First Blog Post — "Hello World"
Persona: Neha, founder of a SaaS startup. She just signed up for Vlozi and wants to publish her first blog post to drive organic traffic.
Goal: Create, write, and publish a blog post from scratch.
- Trigger: Neha navigates to Dashboard → Blog. She sees the empty state: "No blog posts yet."
- Action:
- Clicks "Create New Post". Redirected to
/dashboard/blog/new. - Types the title: "Why We Built Vlozi".
- Switches to the Editor tab. Types the opening paragraph.
- Uses slash commands (
/) to insert a heading, a blockquote, and a bullet list. - Embeds a YouTube video using the YouTube dialog.
- Uploads a featured image via the Settings tab (1200×630px recommended).
- Selects "Company News" from the Category Selector dropdown.
- Adds tags "launch" and "story" via the Tag Input combobox.
- Fills in the SEO title and description.
- Clicks "Save" to persist the draft.
- Reviews the content, then clicks "Publish". Confirms in the dialog.
- Clicks "Create New Post". Redirected to
- Backend Operations:
POST /api/blog/admin/postscreates the post with statusdraft.- The slug
why-we-built-vloziis auto-generated and validated for tenant-level uniqueness. contentJsonstores the TipTap JSON document.upsertCategory()resolves "Company News" →categoryId. The FK is set on the post row.upsertTags()resolves["launch", "story"]→ tag IDs. Junction rows are inserted inblog_post_tags.POST /api/blog/admin/posts/{id}/publishtransitions status topublishedand setspublishedAt.
- Outcome: The post is now live. Visitors on Neha's website (using the SDK) can see it immediately.
Journey B: Editing & Autosave — "The Iterative Writer"
Persona: Neha returns the next day to polish her post.
Goal: Edit an existing published post with confidence that changes are not lost.
- Trigger: Neha navigates to Dashboard → Blog and clicks her published post card.
- Action:
- The editor loads with the existing title, content, and settings pre-filled.
- She rewrites the second paragraph. The autosave indicator shows
Saving...after 30 seconds of inactivity, thenAll changes saved. - She switches to the Settings tab and updates the excerpt.
- She accidentally clicks the browser back button. A confirmation dialog appears: "You have unsaved changes." She clicks "Stay".
- She finishes editing and clicks "Save" manually.
- Backend Operations:
PUT /api/blog/admin/posts/{id}updatescontentJson,excerpt, andupdatedAt.- Autosave fires debounced
PUTrequests every 30 seconds when content changes. - The post remains
publishedthroughout — live readers see updates immediately after save.
- Outcome: Neha's post is polished without any risk of data loss thanks to autosave and unsaved-change guards.
Journey C: Team Collaboration — "The Restricted Author"
Persona: Arjun, a content writer on Neha's team with blog:posts.create, blog:posts.read, and blog:posts.update permissions — but not blog:posts.publish or blog:posts.delete.
Goal: Write a draft post for Neha to review and publish.
- Trigger: Arjun navigates to Dashboard → Blog and clicks "Create New Post".
- Action:
- Writes the full post content using the TipTap editor.
- Adds a featured image and SEO metadata.
- Clicks "Save". The post is saved as a
draft. - The "Publish" button is hidden (no
blog:posts.publishpermission). - The "Delete" button is hidden (no
blog:posts.deletepermission). - Arjun notifies Neha that the draft is ready.
- Action (Neha):
- Opens the draft from the blog list (status badge shows
Draftin amber). - Reviews the content, makes minor edits.
- Clicks "Publish" → confirms → post goes live.
- Opens the draft from the blog list (status badge shows
- Backend Operations:
- Permission middleware checks
x-user-permissionson every request. - Arjun's
PUTandPOSTrequests succeed because he hascreateandupdate. - Arjun's attempt to call
/publishwould return403 Forbidden. - Neha's publish request succeeds because she has
system:owner.
- Permission middleware checks
- Outcome: Clean separation of authoring and publishing responsibilities.
Journey D: Organizing with Categories — "Building a Taxonomy"
Persona: Neha now has 15 blog posts and needs to organize them for readers.
Goal: Create categories, assign posts, and enable filtered browsing.
- Trigger: Neha navigates to Dashboard → Blog → Categories. She sees the empty state.
- Action:
- Clicks "Create Category". A modal opens.
- Types "Product Updates" — the slug preview shows
product-updates. - Clicks Save. Repeats for "Engineering", "Company News", and "Tutorials".
- Returns to Blog → All Posts. Opens her first post.
- Switches to the Settings & SEO tab. Sees the new Category Selector dropdown.
- Selects "Product Updates" from the dropdown. Clicks Save.
- Repeats for other posts, assigning each to the appropriate category.
- Backend Operations:
POST /api/blog/admin/categoriescreates each category withtenant_idscope.generateUniqueSlug()ensures slug uniqueness per tenant.PUT /api/blog/admin/posts/{id}withcategoryIdupdates the post's foreign key.- The category management page calls
GET /api/blog/admin/categorieswhich returns categories with post counts.
- Outcome: Posts are organized under clear categories. The blog list page now shows category badges on each card, and visitors can filter by category on the public website.
Journey E: Tagging Posts — "Cross-Cutting Discovery"
Persona: Neha wants to add fine-grained tags that cut across categories (e.g., a "Tutorials" post and a "Product Updates" post can both be tagged "React").
Goal: Add tags to posts and enable tag-based discovery.
- Trigger: Neha opens a post in the editor and switches to the Settings & SEO tab.
- Action:
- Sees the new Tag Input below the Category Selector.
- Types "react" — no autocomplete suggestions (first tag). Presses Enter.
- A "react" pill appears. Types "sdk" and presses Enter. Two pills now visible.
- Opens another post. Types "react" — autocomplete suggests the existing "react" tag. Clicks it.
- Also types "next.js" (new tag). Saves.
- Later, navigates to Dashboard → Blog → Tags to see all tags with post counts.
- Renames "next.js" to "Next.js" (capitalization fix) via the edit modal.
- Deletes an unused "test" tag via the delete action.
- Backend Operations:
PUT /api/blog/admin/posts/{id}withtags: ["react", "sdk"]callsupsertTags()which:- Slugifies each name:
react→react,sdk→sdk - Inserts new tags with
onConflictDoNothingon(tenantId, slug) - Returns resolved tag IDs
- Slugifies each name:
- Post-tag junction rows are replaced in
blog_post_tags(delete old + insert new). GET /api/blog/admin/tagsreturns all tags with post counts.PUT /api/blog/admin/tags/{id}renames the tag name and regenerates the slug.DELETE /api/blog/admin/tags/{id}cascades throughblog_post_tagsto remove all post associations.
- Outcome: Posts have rich, searchable tags. The SDK supports
<BlogList tag="react" />andclient.blog.list({ tag: "react" })for filtered views.
NOTE
Tags are created inline from the editor — users rarely need the dedicated tag management page. It exists primarily for renaming, auditing usage, and bulk cleanup.
Journey F: SDK Integration — "Embedding the Blog"
Persona: Neha wants her published posts to appear on her company website (https://neha-saas.com/blog).
Goal: Install the SDK and display blog posts with category navigation, tag filtering, and search.
- Trigger: Neha clicks "Integration Guide" on the Blog list page, opening the SDK documentation.
- Action:
- Generates an API key from Dashboard → Settings → API Keys. Notes the Gateway URL.
- Installs the SDK:
npm install @vlozi/blog - Wraps her app in the provider:
import { VloziClient } from '@vlozi/blog'; import { VloziProvider } from '@vlozi/blog/react'; const client = new VloziClient({ apiKey: process.env.NEXT_PUBLIC_VLOZI_API_KEY!, baseUrl: process.env.NEXT_PUBLIC_VLOZI_URL!, }); <VloziProvider client={client}> <App /> </VloziProvider> - Adds
<BlogList columns={3} showPagination />to her/blogpage for all posts. - Adds
<BlogList category="tutorials" />to her/tutorialspage for filtered view. - Uses
<BlogCategoryNav variant="sidebar" showCounts />to build a sidebar navigation. - Uses
<BlogTagNav variant="cloud" showCounts />to show a tag cloud. - Adds a search bar using
usePosts({ search: debouncedQuery, sort: "title", order: "asc" }). - Adds
<BlogPost slug={params.slug} showFeaturedImage />to her/blog/[slug]page.
- Backend Operations:
GET /blog/public/posts?category=tutorialsreturns only published posts in the "tutorials" category.GET /blog/public/posts?search=react&sort=title&order=ascreturns search results sorted alphabetically.GET /blog/public/categoriesreturns all categories with post counts.GET /blog/public/tagsreturns all tags with post counts.- Each post response includes
category: { name, slug }andtags: [{ name, slug }].
- Outcome: Neha's website displays a fully navigable blog with category pages, tag filtering, full-text search, and sorting — all powered by Vlozi.
Journey G: SEO Optimization — "Ranking on Google"
Persona: Neha wants her blog posts to rank well in search results.
Goal: Configure SEO metadata and verify it renders correctly.
- Trigger: Neha opens an existing post and switches to the Settings & SEO tab.
- Action:
- Sets Featured Image to a high-quality 1200×630px banner (used as
og:imagefor social sharing). - Writes SEO Title (55 characters) — the title that appears in Google results.
- Writes SEO Description (155 characters) — the snippet beneath the title in search results.
- Reviews the auto-generated URL slug for readability.
- Clicks "Save".
- Sets Featured Image to a high-quality 1200×630px banner (used as
- Backend Operations:
PUT /api/blog/admin/posts/{id}storesseoTitle,seoDescription,featuredImageUrl.- Public API exposes these fields: SDK consumers can use
post.seoTitleandpost.seoDescriptionfor<meta>tags andog:properties.
- Outcome: When shared on social media or indexed by search engines, the post displays the optimized title, description, and image preview.
Journey H: Post Lifecycle Management — "Unpublish & Delete"
Persona: Neha decides an old post is no longer relevant.
Goal: Remove a post from public view, then delete it entirely.
- Trigger: Neha navigates to the post in the editor.
- Action (Unpublish):
- Clicks "Unpublish". Confirmation dialog: "Unpublishing will hide this post from your website. Continue?"
- Confirms. Status badge changes from
Published(green) toDraft(amber). - The post immediately disappears from the public API — SDK consumers no longer see it.
- Action (Delete):
- Clicks "Delete". Confirmation dialog: "This action cannot be undone. Are you sure?"
- Confirms. Redirected to the blog list. The post is permanently removed.
- Backend Operations:
- Unpublish:
POST /api/blog/admin/posts/{id}/unpublishsets status todraftand clearspublishedAt. - Delete:
DELETE /api/blog/admin/posts/{id}performs a hard delete from the database.
- Unpublish:
- Outcome: Neha maintains full control over what is visible to her audience.
WARNING
Post deletion is permanent (hard delete). There is no trash or soft-delete mechanism in the current implementation. See blog-service.md for known gaps.
Journey I: Visitor Reading Experience — "The Midnight Reader"
Persona: Priya, a potential customer browsing Neha's website at midnight.
Goal: Discover and read a blog post, browse by category and tag, and search for specific topics.
- Trigger: Priya navigates to
https://neha-saas.com/blog. The<BlogList>component loads. - Action:
- Sees a 3-column grid of post cards with titles, excerpts, category badges, tag pills, and publish dates.
- Notices a sidebar with category links (powered by
<BlogCategoryNav>): "Product Updates (5)", "Tutorials (8)", "Engineering (3)". - Notices a tag cloud (powered by
<BlogTagNav>): "react", "nextjs", "typescript", etc. - Clicks "Tutorials" — the list filters to show only tutorial posts.
- Clicks a tag pill "react" — the list filters to posts tagged "react" across all categories.
- Uses the search bar to type "getting started" — results filter in real-time as she types (debounced 300ms).
- Sorts results by title A-Z using the sort dropdown.
- Clicks a post card. Navigated to
/blog/why-we-built-vlozi. - The
<BlogPost>component renders the full article with featured image, category badge, and tags displayed in the header. - Priya clicks a related tag to discover more content, then browses back to the list.
- Backend Operations:
GET /blog/public/postsreturns paginated posts withcategoryandtagsembedded.GET /blog/public/posts?category=tutorialsfilters by category slug.GET /blog/public/posts?tag=reactfilters by tag slug.GET /blog/public/posts?search=getting+started&sort=title&order=ascperforms text search with sorting.GET /blog/public/categoriesreturns category list with post counts for<BlogCategoryNav>.GET /blog/public/tagsreturns tag list with post counts for<BlogTagNav>.- All requests are scoped to Neha's
tenant_idvia the API key.
- Outcome: Priya has a rich, navigable reading experience. Categories, tags, search, and sorting help her find relevant content quickly without scrolling through everything.
4. Edge Cases & Error Paths
| Scenario | System Behavior |
|---|---|
| Slug Collision | If two posts share the same title, generateUniqueSlug() appends a numeric suffix (e.g., my-post-2). The user is never shown an error. |
| Concurrent Edits | Last-write-wins. If two team members edit the same post simultaneously, the last PUT request overwrites. No real-time collaboration in V1. |
| Invalid Slug Change | If the user manually sets an invalid slug (special characters, empty string), the backend rejects it with a 400 and the frontend shows a toast error. |
| Autosave Failure | If an autosave PUT request fails (network error, 500), the indicator shows Unsaved changes. The beforeunload guard prevents accidental navigation. No retry loop — the user can manually save. |
| Expired JWT | The Next.js proxy returns 401. The seller dashboard redirects to the login page. Unsaved content is lost unless autosave fired before expiry. |
| Invalid API Key (SDK) | The gateway returns 401 Unauthorized. The usePosts / usePost hooks surface the error: { error: Error("Vlozi API Error (401): Invalid API key") }. The <BlogList> component shows "Error loading posts". |
| No Published Posts | The public API returns { data: [], meta: { total: 0 } }. The <BlogList> component renders "No posts found." — a clean empty state, not an error. |
| Large Content Payload | TipTap JSON for long posts can be large. The blog service has no explicit size limit. Extremely large posts may cause slow rendering on the public endpoint. |
| Missing Featured Image | Posts without a featured image simply omit the field. SDK consumers receive featuredImageUrl: null. Components handle this gracefully with fallback styling. |
| Permission Denied | If a team member without blog:posts.publish attempts to call the publish endpoint directly, the middleware returns 403. The frontend hides the button proactively, but the backend enforces regardless. |
| Duplicate Category Name | Backend enforces unique (tenantId, slug). If user creates "Tech" when "tech" already exists, the slug collision triggers a 409 Conflict. The modal shows: "A category with this name already exists." |
| Delete Category with Posts | The category is deleted. All posts referencing it have categoryId set to null (onDelete: 'set null'). Posts themselves remain intact. The confirmation dialog shows the affected post count. |
| Delete Tag with Posts | The junction rows in blog_post_tags cascade-delete. Posts remain intact but lose that tag association. The confirmation dialog shows the affected post count. |
| Tag Limit Exceeded | The editor enforces a maximum of 10 tags per post. Attempting to add an 11th shows a toast: "Maximum 10 tags per post." |
| Empty Category/Tag Name | The create/edit modal disables the Save button when the name input is empty. Backend validates with Zod. |
| Orphaned Tags | Tags with 0 posts are not auto-deleted. They remain available for future use. The tag management page shows post counts so owners can clean up manually. |
| Category/Tag in SDK Filter | If a visitor filters by a non-existent category slug (e.g., ?category=nonexistent), the public API returns an empty data: [] — not an error. The SDK renders "No posts found." |
5. Cross-References
- Frontend Spec — Detailed UI component specifications
- Blog Service — Backend API and middleware documentation
- Blog Workflow — Request flow diagrams and debugging guide
- Blog Engine Vision — Product roadmap and future phases
- API Keys Spec — API key generation and validation