logicspike/docs

Blog Engine

Blog Engine — User Journeys

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.

  1. Trigger: Neha navigates to Dashboard → Blog. She sees the empty state: "No blog posts yet."
  2. 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.
  3. Backend Operations:
    • POST /api/blog/admin/posts creates the post with status draft.
    • The slug why-we-built-vlozi is auto-generated and validated for tenant-level uniqueness.
    • contentJson stores 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 in blog_post_tags.
    • POST /api/blog/admin/posts/{id}/publish transitions status to published and sets publishedAt.
  4. 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.

  1. Trigger: Neha navigates to Dashboard → Blog and clicks her published post card.
  2. 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, then All 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.
  3. Backend Operations:
    • PUT /api/blog/admin/posts/{id} updates contentJson, excerpt, and updatedAt.
    • Autosave fires debounced PUT requests every 30 seconds when content changes.
    • The post remains published throughout — live readers see updates immediately after save.
  4. 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.

  1. Trigger: Arjun navigates to Dashboard → Blog and clicks "Create New Post".
  2. 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.publish permission).
    • The "Delete" button is hidden (no blog:posts.delete permission).
    • Arjun notifies Neha that the draft is ready.
  3. Action (Neha):
    • Opens the draft from the blog list (status badge shows Draft in amber).
    • Reviews the content, makes minor edits.
    • Clicks "Publish" → confirms → post goes live.
  4. Backend Operations:
    • Permission middleware checks x-user-permissions on every request.
    • Arjun's PUT and POST requests succeed because he has create and update.
    • Arjun's attempt to call /publish would return 403 Forbidden.
    • Neha's publish request succeeds because she has system:owner.
  5. 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.

  1. Trigger: Neha navigates to Dashboard → Blog → Categories. She sees the empty state.
  2. 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.
  3. Backend Operations:
    • POST /api/blog/admin/categories creates each category with tenant_id scope.
    • generateUniqueSlug() ensures slug uniqueness per tenant.
    • PUT /api/blog/admin/posts/{id} with categoryId updates the post's foreign key.
    • The category management page calls GET /api/blog/admin/categories which returns categories with post counts.
  4. 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.

  1. Trigger: Neha opens a post in the editor and switches to the Settings & SEO tab.
  2. 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.
  3. Backend Operations:
    • PUT /api/blog/admin/posts/{id} with tags: ["react", "sdk"] calls upsertTags() which:
      • Slugifies each name: reactreact, sdksdk
      • Inserts new tags with onConflictDoNothing on (tenantId, slug)
      • Returns resolved tag IDs
    • Post-tag junction rows are replaced in blog_post_tags (delete old + insert new).
    • GET /api/blog/admin/tags returns 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 through blog_post_tags to remove all post associations.
  4. Outcome: Posts have rich, searchable tags. The SDK supports <BlogList tag="react" /> and client.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.

  1. Trigger: Neha clicks "Integration Guide" on the Blog list page, opening the SDK documentation.
  2. 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 /blog page for all posts.
    • Adds <BlogList category="tutorials" /> to her /tutorials page 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.
  3. Backend Operations:
    • GET /blog/public/posts?category=tutorials returns only published posts in the "tutorials" category.
    • GET /blog/public/posts?search=react&sort=title&order=asc returns search results sorted alphabetically.
    • GET /blog/public/categories returns all categories with post counts.
    • GET /blog/public/tags returns all tags with post counts.
    • Each post response includes category: { name, slug } and tags: [{ name, slug }].
  4. 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.

  1. Trigger: Neha opens an existing post and switches to the Settings & SEO tab.
  2. Action:
    • Sets Featured Image to a high-quality 1200×630px banner (used as og:image for 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".
  3. Backend Operations:
    • PUT /api/blog/admin/posts/{id} stores seoTitle, seoDescription, featuredImageUrl.
    • Public API exposes these fields: SDK consumers can use post.seoTitle and post.seoDescription for <meta> tags and og: properties.
  4. 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.

  1. Trigger: Neha navigates to the post in the editor.
  2. Action (Unpublish):
    • Clicks "Unpublish". Confirmation dialog: "Unpublishing will hide this post from your website. Continue?"
    • Confirms. Status badge changes from Published (green) to Draft (amber).
    • The post immediately disappears from the public API — SDK consumers no longer see it.
  3. 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.
  4. Backend Operations:
    • Unpublish: POST /api/blog/admin/posts/{id}/unpublish sets status to draft and clears publishedAt.
    • Delete: DELETE /api/blog/admin/posts/{id} performs a hard delete from the database.
  5. 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.

  1. Trigger: Priya navigates to https://neha-saas.com/blog. The <BlogList> component loads.
  2. 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.
  3. Backend Operations:
    • GET /blog/public/posts returns paginated posts with category and tags embedded.
    • GET /blog/public/posts?category=tutorials filters by category slug.
    • GET /blog/public/posts?tag=react filters by tag slug.
    • GET /blog/public/posts?search=getting+started&sort=title&order=asc performs text search with sorting.
    • GET /blog/public/categories returns category list with post counts for <BlogCategoryNav>.
    • GET /blog/public/tags returns tag list with post counts for <BlogTagNav>.
    • All requests are scoped to Neha's tenant_id via the API key.
  4. 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

Blog Engine