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 ID | Declared path | Friendly URL | Editable |
|---|---|---|---|
blogHome | /blog | /blog | Yes |
blogRss | /blog/rss.xml | /blog/rss.xml | No |
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:
| Event | Subscriber | Effect |
|---|---|---|
blog_post_created / blog_post_updated | subscribers/blog_post_*/buildUrlRewrite.ts | Upserts /blog/{url_key} → /blogPost/{uuid} |
blog_category_created / blog_category_updated | subscribers/blog_category_*/buildUrlRewrite.ts | Upserts /blog/category/{url_key} → /blogCategory/{uuid} |
blog_tag_created / blog_tag_updated | subscribers/blog_tag_*/buildUrlRewrite.ts | Upserts /blog/tag/{url_key} → /blogTag/{uuid} |
blog_post_deleted / blog_category_deleted / blog_tag_deleted | subscribers/*_deleted/deleteUrlRewrite.ts | Deletes 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 key | Set by | Used by |
|---|---|---|
currentBlogPostId | blogPostView | currentBlogPost query |
currentBlogCategoryId | blogCategoryView | currentBlogCategory query |
currentBlogTagId | blogTagView | currentBlogTag query |
blogVisitor | blogPostView | BlogComment.liked |
blogPostReactedType | blogPostView | BlogPost.reactions[].reacted |
filtersFromUrl | blogHome, blogCategoryView, blogTagView | Collection 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 unpublished — blogPostView and blogCategoryView both add status = 1 to their lookup.
Admin routes
| Route ID | Path (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.
| Table | Purpose | Notable columns |
|---|---|---|
blog_category | Category record | uuid, status (default 1), comment_policy (default moderated), position, meta_data (JSONB) |
blog_category_description | Translatable category fields | name, short_description, url_key (UNIQUE), meta_title, meta_description |
blog_tag | Tag record (single table, no description split) | uuid, name, url_key (UNIQUE), meta_title, meta_description, created_at |
blog_post | Post record | uuid, status (default 0), category_id, author_id, thumbnail, reaction_counts (JSONB), comment_count, reading_time, published_at, meta_data (JSONB) |
blog_post_description | Translatable post fields | name, short_description, description (text — JSON blocks), url_key (UNIQUE), meta_title, meta_description |
blog_post_tag | Many-to-many pivot | post_id, tag_id, UNIQUE on the pair |
blog_comment | Comment, self-referencing for threads | uuid, post_id, parent_id, customer_id, name, email, comment, status (default pending), like_count |
blog_reaction | Post reactions and comment likes | entity_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_idandblog_comment.customer_idcarry no database foreign key. Module migration order across modules is not guaranteed, so a hard FK toadmin_user/customercould run before those tables exist. The resolvers tolerate a missing or orphaned id and returnnull.blog_reactionis 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.
| Service | Registry key | Event emitted |
|---|---|---|
createBlogPost | blogPostDataBeforeCreate | blog_post_created |
updateBlogPost | blogPostDataBeforeUpdate | blog_post_updated |
deleteBlogPost | — | blog_post_deleted |
createBlogCategory | blogCategoryDataBeforeCreate | blog_category_created |
updateBlogCategory | blogCategoryDataBeforeUpdate | blog_category_updated |
deleteBlogCategory | — | blog_category_deleted |
createBlogTag | blogTagDataBeforeCreate | blog_tag_created |
updateBlogTag | blogTagDataBeforeUpdate | blog_tag_updated |
deleteBlogTag | — | blog_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.
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''tonullbefore the insert, because the integer columns reject the empty string. published_atis stamped on first publish. Whenstatusis1andpublished_atis not already set, the service stamps the current ISO timestamp. There is no scheduler — a post is visible on the storefront whenstatus = 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 → columns → data.blocks) that components/common/Editor.tsx renders and counts words per block type:
| Block type | Counted as |
|---|---|
paragraph, header, quote | Words in data.text plus data.caption |
list | Words across data.items |
raw | Words in data.html (tags stripped) |
image | An image, not words |
productList | 12 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_policy | Result of submitBlogComment |
|---|---|
open | Stored with status = 'approved' and immediately visible |
moderated (default) | Stored with status = 'pending', hidden until an admin approves |
closed | CommentsClosedError → 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:
nameandcommentgo throughsanitizeHtmlwith no allowed tags or attributes, then whitespace is collapsed and the value is truncated (120 characters forname, 5000 forcomment). This is deliberately notsanitizeRawHtml, which is Editor.js-specific and permissive.- A hidden
websitefield acts as a honeypot. Humans never see it; a bot that fills it getsstatus = 'spam'. - A comment containing more than three
http(s)://occurrences is also auto-markedspam.
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) callsresolveReactor(request, response, true). If the cookie is absent, a freshrandomUUID()is generated and set on the response. - Read path (
blogPostViewGET) callsresolveReactor(request)withissuedefaulting tofalse, so a GET never sets a cookie. It returnsnullwhen no cookie exists, and the resolvers simply reportreacted: 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
| Query | Returns | Notes |
|---|---|---|
blogPost(id: ID) | BlogPost | id is the post uuid |
blogPosts(filters: [FilterInput]) | BlogPostCollection | Non-admin callers are restricted to status = 1 |
currentBlogPost | BlogPost | Returns null unless the current route is blogPostView |
blogCategory(id: ID) | BlogCategory | |
blogCategories(filters: [FilterInput]) | BlogCategoryCollection | |
currentBlogCategory | BlogCategory | Bound to blogCategoryView |
blogTag(id: ID) | BlogTag | |
blogTags(filters: [FilterInput]) | BlogTagCollection | |
currentBlogTag | BlogTag | Bound to blogTagView |
blogComments(filters: [FilterInput]) | BlogCommentCollection | Admin only — declared in BlogComment.admin.graphql, powers the moderation grid |
featuredBlogsWidget(...) | FeaturedBlogsWidget | Backs 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:
urllooks up theurl_rewriterow and falls back to/blog/<urlKey>, then runs the result throughlocalizeUrlso the locale prefix is preserved.descriptionisJSON, not HTML. It is the Editor.js block array; the resolver parses the stored string and returns[]on malformed JSON.reactionsalways returns one entry per type inREACTION_TYPES, withcount: 0for types nobody has used, andreactedtrue for the type matchingblogPostReactedTypein 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):
| Key | Operations | Effect |
|---|---|---|
name | eq, like | Exact match or ILIKE %value% on the post name |
keyword | eq | ILIKE %value% on the post name |
status | eq | Filters blog_post.status |
category | eq, in | in takes a comma-separated list of category ids |
tag | eq | Joins blog_post_tag and filters by tag id |
ob | eq | Order 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.
The featured_blogs widget
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 URNs and link loaders
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 type | Value storage |
|---|---|
blog_post | blog_post.meta_data |
blog_category | blog_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_deleted — pruneBlogPost.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
})
);
| Collector | Table | Filter | changefreq | priority |
|---|---|---|---|---|
blog-posts | blog_post | e.status = 1 | weekly | 0.5 |
blog-categories | blog_category | e.status = 1 | weekly | 0.4 |
blog-tags | blog_tag | none | monthly | 0.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
- Metafields — custom fields on
blog_postandblog_category - Sitemap — how blog collectors plug into
/sitemap.xml - Events and Subscribers — the
blog_*_created/updated/deletedevents - Registry and Processors — the
blog*DataBefore*andblog*CollectionFilterskeys - Routing System —
url_rewriteresolution and route matching order
Support us
EverShop is an open-source project that relies on community support. If you find our project useful, please consider sponsoring us.