Skip to main content

Blog Module

The blog module is a core EverShop module (packages/evershop/src/modules/blog) that adds posts, categories, tags, comments, and reactions to the storefront. It is enabled by default — there is nothing to install. Like every other core module it owns its own migration, GraphQL types, admin pages, storefront pages, REST endpoints, and a bootstrap.ts that registers a widget, sitemap collectors, URN link loaders, and collection filters.

This page describes how the module is wired. For the HTTP surface, see the Blog REST API.

Storefront routes and friendly URLs

The module declares five storefront routes. Two of them are addressable directly; the other three are only ever reached through a url_rewrite row.

Route IDDeclared pathFriendly URLEditable
blogHome/blog/blogYes
blogRss/blog/rss.xml/blog/rss.xmlNo
blogPostView/blogPost/:uuid/blog/<slug>Yes
blogCategoryView/blogCategory/:uuid/blog/category/<slug>Yes
blogTagView/blogTag/:uuid/blog/tag/<slug>Yes

editable: true means the page participates in the page builder, so widgets can be placed on it.

How the friendly URLs are built

Post, category, and tag URLs are not hardcoded in the router. They come from rows in the shared url_rewrite table, written by event subscribers when the entity is created or updated and removed when it is deleted:

EventSubscriberEffect
blog_post_created / blog_post_updatedsubscribers/blog_post_*/buildUrlRewrite.tsUpserts /blog/{url_key}/blogPost/{uuid}
blog_category_created / blog_category_updatedsubscribers/blog_category_*/buildUrlRewrite.tsUpserts /blog/category/{url_key}/blogCategory/{uuid}
blog_tag_created / blog_tag_updatedsubscribers/blog_tag_*/buildUrlRewrite.tsUpserts /blog/tag/{url_key}/blogTag/{uuid}
blog_post_deleted / blog_category_deleted / blog_tag_deletedsubscribers/*_deleted/deleteUrlRewrite.tsDeletes the row by entity_uuid + entity_type

The upsert uses entity_uuid as the conflict key, so renaming a post replaces its request_path rather than accumulating stale rewrites. Every subscriber is a no-op when url_key is missing, and every one swallows its own errors through error() so a rewrite failure never rolls back the entity write — the entity is still reachable at its internal /blogPost/<uuid> path.

The entity_type values written are blog_post, blog_category, and blog_tag.

/blog is a reserved slug

assertUrlKeyAvailable (modules/base/services/assertUrlKeyAvailable.ts) refuses any url_key that matches a single-segment, non-admin, non-API storefront route. It builds that list dynamically from the live route table rather than from a hardcoded array:

const reserved = getRoutes()
.filter((r) => !r.isAdmin && !r.isApi && /^\/[a-zA-Z0-9-]+$/.test(r.path))
.map((r) => r.path);

Because blogHome declares /blog, creating a CMS page or a landing page with url_key: "blog" throws:

URL key "blog" is reserved by a system route and would be unreachable.

This is not cosmetic — the route matcher runs before the url_rewrite fallback, so a CMS page at /blog would be permanently shadowed by the blog home page. Note that the other blog routes are not reserved: /blogPost/:uuid, /blogCategory/:uuid, and /blogTag/:uuid contain a parameter and /blog/rss.xml has two segments, so none of them match the single-segment pattern.

The RSS feed

blogRss is a plain middleware page with no React component. It selects the 20 most recent published posts, renders RSS 2.0 by hand, sets Content-Type: application/rss+xml; charset=utf-8 plus Cache-Control: public, max-age=3600, and calls response.send() without calling next() — which short-circuits the React render pipeline. Item links are built from the pretty path (<baseUrl>/blog/<url_key>).

Page context values

The storefront middlewares publish context so resolvers can find the current entity:

Context keySet byUsed by
currentBlogPostIdblogPostViewcurrentBlogPost query
currentBlogCategoryIdblogCategoryViewcurrentBlogCategory query
currentBlogTagIdblogTagViewcurrentBlogTag query
blogVisitorblogPostViewBlogComment.liked
blogPostReactedTypeblogPostViewBlogPost.reactions[].reacted
filtersFromUrlblogHome, blogCategoryView, blogTagViewCollection paging / filtering

All three view pages also set request.locals.pageBuilderEntityUrn to the entity's URN so page-builder content can be scoped per entity, and call setPageMetaInfo with breadcrumbs. blogPostView additionally emits Open Graph article metadata (publishedTime, authors, tags, image).

Each view page returns a 404 when the entity is missing or unpublishedblogPostView and blogCategoryView both add status = 1 to their lookup.

Admin routes

Route IDPath (auto-prefixed with /admin)
blogPostGrid/blog/posts
blogPostNew/blog/posts/new
blogPostEdit/blog/posts/edit/:id
blogCategoryGrid/blog/categories
blogCategoryNew/blog/categories/new
blogCategoryEdit/blog/categories/edit/:id
blogTagGrid/blog/tags
blogTagNew/blog/tags/new
blogTagEdit/blog/tags/edit/:id
blogCommentGrid/blog/comments

Data model

Everything is created by a single migration, modules/blog/migration/Version-1.0.0.ts, in FK-dependency order.

TablePurposeNotable columns
blog_categoryCategory recorduuid, status (default 1), comment_policy (default moderated), position, meta_data (JSONB)
blog_category_descriptionTranslatable category fieldsname, short_description, url_key (UNIQUE), meta_title, meta_description
blog_tagTag record (single table, no description split)uuid, name, url_key (UNIQUE), meta_title, meta_description, created_at
blog_postPost recorduuid, status (default 0), category_id, author_id, thumbnail, reaction_counts (JSONB), comment_count, reading_time, published_at, meta_data (JSONB)
blog_post_descriptionTranslatable post fieldsname, short_description, description (text — JSON blocks), url_key (UNIQUE), meta_title, meta_description
blog_post_tagMany-to-many pivotpost_id, tag_id, UNIQUE on the pair
blog_commentComment, self-referencing for threadsuuid, post_id, parent_id, customer_id, name, email, comment, status (default pending), like_count
blog_reactionPost reactions and comment likesentity_type, entity_id, reaction_type, fingerprint, UNIQUE on (entity_type, entity_id, fingerprint)

Two deliberate design decisions in the schema are worth knowing:

  • blog_post.author_id and blog_comment.customer_id carry no database foreign key. Module migration order across modules is not guaranteed, so a hard FK to admin_user / customer could run before those tables exist. The resolvers tolerate a missing or orphaned id and return null.
  • blog_reaction is UNIQUE on (entity_type, entity_id, fingerprint) — the reaction type is deliberately excluded. A visitor therefore holds at most one reaction per post; picking a different type switches it rather than adding a second.

url_key triggers

The migration installs a blog-specific build_blog_url_key() PL/pgSQL function and attaches it as a BEFORE INSERT OR UPDATE trigger on blog_post_description, blog_category_description, and blog_tag. It slugifies NEW.name when url_key is NULL, and rejects an explicit url_key containing /, \ or #.

Unlike catalog's build_url_key, it does not append a random numeric suffix — blog slugs stay clean. The consequence is that two posts with the same name collide on the url_key UNIQUE constraint; the author must set an explicit url_key to disambiguate.

Denormalized counters

blog_post.comment_count, blog_post.reaction_counts, and blog_comment.like_count are caches. Every write path that can change them recomputes them from the source rows inside the same transaction — for example submitBlogComment, moderateBlogComment, and deleteBlogComment all re-run SELECT COUNT(*) ... WHERE status='approved', and reactToBlogPost rebuilds reaction_counts with jsonb_object_agg over blog_reaction.

Services, hooks, and events

The write services live in modules/blog/services/ and are re-exported from services/index.ts. Each follows the standard EverShop shape: a hookable-wrapped implementation, hookBefore* / hookAfter* helpers, a registry key for the data payload, and an event emitted after commit.

ServiceRegistry keyEvent emitted
createBlogPostblogPostDataBeforeCreateblog_post_created
updateBlogPostblogPostDataBeforeUpdateblog_post_updated
deleteBlogPostblog_post_deleted
createBlogCategoryblogCategoryDataBeforeCreateblog_category_created
updateBlogCategoryblogCategoryDataBeforeUpdateblog_category_updated
deleteBlogCategoryblog_category_deleted
createBlogTagblogTagDataBeforeCreateblog_tag_created
updateBlogTagblogTagDataBeforeUpdateblog_tag_updated
deleteBlogTagblog_tag_deleted

Each create/update service also runs its AJV data schema through a registry key so extensions can widen it: createBlogPostDataJsonSchema, updateBlogPostDataJsonSchema, and the equivalent ...BlogCategory... / ...BlogTag... keys.

note

Unlike catalog, cms, oms, and the other core modules, the blog module has no @evershop/evershop/blog/services subpath in package.json as of v2.2.1. The hookBefore* / hookAfter* helpers exist and are re-exported from modules/blog/services/index.ts, but they are not reachable from an extension through a published import path. Extend blog writes through the registry keys and events above instead — both are public API.

Adding a field to the post payload and reacting to the write, from an extension bootstrap.ts:

import { addProcessor } from '@evershop/evershop/lib/util/registry';

export default function bootstrap() {
addProcessor('blogPostDataBeforeCreate', async function (data) {
// `this` is the processor context — do not use an arrow function here.
return data;
});

addProcessor('createBlogPostDataJsonSchema', function (schema) {
schema.properties.reviewed_by = { type: ['string', 'null'] };
return schema;
});
}

Both must be registered from bootstrap.ts — the registry locks once bootstrap finishes.

Two behaviours are shared by createBlogPost and updateBlogPost:

  • Empty foreign keys are coerced. The admin form sends '' for an unselected category or author; both services rewrite '' to null before the insert, because the integer columns reject the empty string.
  • published_at is stamped on first publish. When status is 1 and published_at is not already set, the service stamps the current ISO timestamp. There is no scheduler — a post is visible on the storefront when status = 1, full stop.

updateBlogPost and updateBlogCategory tolerate a payload that touches only the base table: the description update is wrapped in a try/catch that swallows the query builder's No data was provided error.

Reading time

services/readingTime.ts exports a pure, side-effect-free readingTime(content, options). It walks the Editor.js document shape (rows → columnsdata.blocks) that components/common/Editor.tsx renders and counts words per block type:

Block typeCounted as
paragraph, header, quoteWords in data.text plus data.caption
listWords across data.items
rawWords in data.html (tags stripped)
imageAn image, not words
productList12 words per product

Words are divided by wpm (default 200). Images add decaying time — the first image costs secondsPerImage (default 10), each subsequent image one second less, with a floor of 3 seconds. The result is Math.ceil of the total minutes, minimum 1.

Because it is pure, it runs inside the create/update transaction and its output is cached in blog_post.reading_time. The GraphQL resolver prefers the cached value and recomputes from description only when the cache is absent or non-positive:

readingTime: ({ readingTime: cached, description }) =>
typeof cached === 'number' && cached > 0 ? cached : readingTime(description)

updateBlogPost recomputes reading_time only when description is supplied — an update that changes just the title leaves the cached value alone.

Comments and moderation

A comment's fate is decided by the category's comment_policy, not the post's:

comment_policyResult of submitBlogComment
openStored with status = 'approved' and immediately visible
moderated (default)Stored with status = 'pending', hidden until an admin approves
closedCommentsClosedError → the API responds 403

A post with no category falls back to moderated.

services/comment/submitBlogComment.ts treats the input as hostile, since comments are untrusted text rendered back to other visitors:

  • name and comment go through sanitizeHtml with no allowed tags or attributes, then whitespace is collapsed and the value is truncated (120 characters for name, 5000 for comment). This is deliberately not sanitizeRawHtml, which is Editor.js-specific and permissive.
  • A hidden website field acts as a honeypot. Humans never see it; a bot that fills it gets status = 'spam'.
  • A comment containing more than three http(s):// occurrences is also auto-marked spam.

Spam comments are still stored — they are simply never shown. Only status = 'approved' comments reach the storefront.

moderateBlogComment(uuid, status) accepts exactly pending, approved, or spam and recomputes the post's comment_count in the same transaction. deleteBlogComment(uuid) deletes the comment (replies cascade via the parent_id FK), purges the comment's blog_reaction rows, and recomputes comment_count.

The BlogComment storefront resolver loads all approved comments for a post in one query and assembles the reply tree in JavaScript, avoiding N+1 recursion. Note that email is declared only on BlogComment.admin.graphql — it is write-only and never exposed on the storefront.

Reactions and reactor resolution

Reactions are visitor-scoped, not customer-scoped. The identity used is a fingerprint produced by services/reaction/resolveReactor.ts:

export function resolveReactor(
request: EvershopRequest,
response?: EvershopResponse,
issue = false
): string | null;

The fingerprint is the value of a signed, httpOnly, sameSite: 'lax' cookie named blog_visitor with a one-year maxAge. There is no IP or user-agent hashing.

The issue flag is what separates the read path from the write path:

  • Write path (POST .../react, POST .../like) calls resolveReactor(request, response, true). If the cookie is absent, a fresh randomUUID() is generated and set on the response.
  • Read path (blogPostView GET) calls resolveReactor(request) with issue defaulting to false, so a GET never sets a cookie. It returns null when no cookie exists, and the resolvers simply report reacted: false / liked: false.

Reaction types are fixed lists in services/reaction/reactionTypes.ts:

export const REACTION_TYPES = ['like', 'love', 'clap', 'insightful'] as const;

Posts accept all four. Comments only ever use 'like'likeBlogComment is the degenerate single-type case and takes no type argument.

Toggle semantics differ slightly between the two:

  • reactToBlogPost(postId, type, fingerprint) — same type again removes the reaction (reacted: null); a different type updates the existing row in place (a visitor never holds two reactions on one post); no row yet inserts one. Returns { counts, reacted }.
  • likeBlogComment(commentId, fingerprint) — a pure toggle. Returns { likeCount, liked }.

Both recompute the denormalized counter from blog_reaction inside the transaction.

GraphQL surface

The module ships storefront types plus .admin.graphql extensions. Remember that the two schemas build separately: a type defined in an .admin.graphql file is invisible to the storefront schema.

Queries

QueryReturnsNotes
blogPost(id: ID)BlogPostid is the post uuid
blogPosts(filters: [FilterInput])BlogPostCollectionNon-admin callers are restricted to status = 1
currentBlogPostBlogPostReturns null unless the current route is blogPostView
blogCategory(id: ID)BlogCategory
blogCategories(filters: [FilterInput])BlogCategoryCollection
currentBlogCategoryBlogCategoryBound to blogCategoryView
blogTag(id: ID)BlogTag
blogTags(filters: [FilterInput])BlogTagCollection
currentBlogTagBlogTagBound to blogTagView
blogComments(filters: [FilterInput])BlogCommentCollectionAdmin only — declared in BlogComment.admin.graphql, powers the moderation grid
featuredBlogsWidget(...)FeaturedBlogsWidgetBacks the featured_blogs widget

BlogPost

type BlogPost {
blogPostId: Int
uuid: String!
status: Int!
name: String!
urlKey: String!
shortDescription: String
description: JSON
thumbnail: String
publishedAt: String
readingTime: Int!
commentCount: Int!
reactions: [BlogReactionCount!]!
url: String!
category: BlogCategory
author: BlogAuthor
tags: [BlogTag!]!
related(limit: Int = 3): [BlogPost!]!
metaTitle: String
metaDescription: String
comments: [BlogComment!]!
}

Resolver behaviour worth knowing:

  • url looks up the url_rewrite row and falls back to /blog/<urlKey>, then runs the result through localizeUrl so the locale prefix is preserved.
  • description is JSON, not HTML. It is the Editor.js block array; the resolver parses the stored string and returns [] on malformed JSON.
  • reactions always returns one entry per type in REACTION_TYPES, with count: 0 for types nobody has used, and reacted true for the type matching blogPostReactedType in context.
  • related(limit) returns published posts in the same category, excluding the post itself, newest first.

The admin schema adds metaData: JSON, authorId, editUrl, updateApi, and deleteApi to BlogPost; editUrl / updateApi / deleteApi to BlogCategory and BlogTag; and moderateApi / deleteApi to BlogComment.

Collection filters

blogPosts supports these filter keys, plus the shared pagination filters (page, limit, od):

KeyOperationsEffect
nameeq, likeExact match or ILIKE %value% on the post name
keywordeqILIKE %value% on the post name
statuseqFilters blog_post.status
categoryeq, inin takes a comma-separated list of category ids
tageqJoins blog_post_tag and filters by tag id
obeqOrder by name, published_at, or created_at

blogCategories supports name and status; blogTags supports name; blogComments supports status and keyword. All four filter sets are registered from bootstrap.ts via addProcessor on blogPostCollectionFilters, blogCategoryCollectionFilters, blogTagCollectionFilters, and blogCommentCollectionFilters — so an extension can add its own filter the same way.

bootstrap.ts registers one widget:

registerWidget({
type: 'featured_blogs',
name: 'Featured blogs',
description: 'A list of featured blog posts',
category: 'content',
icon: 'Newspaper',
enabled: true,
defaultSettings: {
eyebrow: '',
heading: '',
subText: '',
postUuids: [],
count: 3,
columns: 3
}
});

Its three components live in the module: components/admin/FeaturedBlogsSetting.tsx, components/admin/FeaturedBlogsPreview.tsx, and components/frontStore/FeaturedBlogs.tsx.

The settings schema constrains count to 1–24 and columns to one of [1, 2, 3, 4]. postUuids is an explicitly curated, ordered list — the featuredBlogsWidget resolver fetches the matching published posts and re-orders them to match the pick order before slicing to count:

const ordered = uuids.map((u) => byUuid.get(u)).filter(Boolean);

An empty postUuids returns no posts — the widget does not auto-fill with recent posts.

Like every widget, registerWidget must be called from bootstrap.ts; the registry is locked afterwards.

Blog entities are addressable by URN in the form urn:evershop:blog:<type>:<uuid>, with three registered types:

  • urn:evershop:blog:post:<uuid>
  • urn:evershop:blog:category:<uuid>
  • urn:evershop:blog:tag:<uuid>

modules/blog/lib/BlogUrn.ts is a thin re-export — the schemas themselves are registered centrally in lib/urn/index.ts alongside the catalog and CMS schemas so they are available on both client and server:

export const BlogUrn = {
post: (uuid: string) => UrnService.build('blog', 'post', uuid),
category: (uuid: string) => UrnService.build('blog', 'category', uuid),
tag: (uuid: string) => UrnService.build('blog', 'tag', uuid)
};

bootstrap.ts registers a link loader for each type so a URN can be resolved to a live URL at request time. Each loader is built with linkLoaderFromBatch, batching the uuids into one url_rewrite lookup and falling back to the internal route when no rewrite exists:

registerLinkLoader('blog', 'post', blogLinkLoader('blog_post', 'blogPostView'));
registerLinkLoader('blog', 'category', blogLinkLoader('blog_category', 'blogCategoryView'));
registerLinkLoader('blog', 'tag', blogLinkLoader('blog_tag', 'blogTagView'));

This is what lets a page-builder link or a rich-text mention point at a post without hardcoding its slug — rename the post and the URN still resolves.

Metafields

Blog posts and blog categories are metafield owners. The owner types are:

Owner typeValue storage
blog_postblog_post.meta_data
blog_categoryblog_category.meta_data

Blog tags are not metafield owners — blog_tag has no meta_data column.

bootstrap.ts wires the write path by registering a folder on the same registry keys the services already run:

function makeMetafieldFolder(ownerType: string) {
return async function foldMetafields(data) {
if (data && data.metafields !== undefined) {
data.meta_data = await validateMetafields(ownerType, data.metafields);
}
return data;
};
}

addProcessor('blogPostDataBeforeCreate', makeMetafieldFolder('blog_post'));
addProcessor('blogPostDataBeforeUpdate', makeMetafieldFolder('blog_post'));

The folder runs only when metafields is explicitly present in the payload, so an ordinary API update that omits the key leaves meta_data untouched rather than wiping it.

On the read side, BlogPostMetafields.graphql and BlogCategoryMetafields.graphql extend both types with metafields(namespace: String) and metafield(namespace: String!, key: String!).

Deleting a metafield definition is cleaned up by two subscribers on metafield_definition_deletedpruneBlogPost.ts and pruneBlogCategory.ts — which strip the key from every row's meta_data with the JSONB #- operator. Both are idempotent and no-op when the deleted definition belongs to a different owner type.

See Metafields for definition management, field types, and validation.

Sitemap

Blog registers its own sitemap collectors from bootstrap.ts — nothing in modules/base knows about blog:

registerSitemapCollector(
createEntityCollector({
name: 'blog-posts',
table: 'blog_post',
entityType: 'blog_post',
where: 'e.status = 1',
changefreq: 'weekly',
priority: 0.5
})
);
CollectorTableFilterchangefreqpriority
blog-postsblog_poste.status = 1weekly0.5
blog-categoriesblog_categorye.status = 1weekly0.4
blog-tagsblog_tagnonemonthly0.3

The tag collector passes updatedAtColumn: 'created_at' because blog_tag has no updated_at column. Each collector is served at /sitemap-<name>.xml — so /sitemap-blog-posts.xml, /sitemap-blog-categories.xml, and /sitemap-blog-tags.xml — and referenced from the /sitemap.xml index. Each inherits multi-language hreflang, 50,000-per-file chunking, and change detection for free.

The /blog landing page itself is not an entity, so include it through the sitemap's staticPaths configuration if you want it listed. See Sitemap.

REST API

Fourteen endpoints, eleven admin-only and three unauthenticated public write surfaces (POST /api/blog/comments, POST /api/blog/comments/:id/like, POST /api/blog/posts/:id/react). See the Blog REST API reference for payloads and responses.

See also



Support us


EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.