Set up the Blog Manager integration on a deco storefront. Follow every step exactly — this runs across many sites and must be consistent.
Step 1 — Verify dependency version
Before creating any files, verify that the apps dependency meets the minimum version:
appsmust be at least 0.144.0
Check deno.json (or import_map.json) for the entry that resolves apps/:
"apps/": "https://deno.land/x/deco_apps@0.x.x/"
If the version is below the required minimum, stop and tell the user:
"The
appsdependency is currently at<version>. Please update it to at least0.144.0before continuing — the blog requires features introduced in that version."
Only proceed once the version requirement is satisfied.
Step 2 — Create blog app re-export file
Create apps/deco/blog.ts:
export { default } from "apps/blog/mod.ts";
export * from "apps/blog/mod.ts";
Step 3 — Register the blog app as a deco block
Create .deco/blocks/deco-blog.json:
{ "__resolveType": "site/apps/deco/blog.ts" }
Step 4 — Find the correct Header and Footer names
The Header and Footer __resolveType values vary per site — do not assume "Global Header" and "Global Footer".
Before creating the page files, inspect the site's homepage block to find the exact names in use:
- Read
.deco/blocks/pages-home.json(or whichever file contains the/route). - Find the Lazy sections that wrap the Header and Footer — copy their
__resolveTypevalues exactly. - Use those values in Steps 5 and 6 below wherever
"Global Header"and"Global Footer"appear.
Step 5 — Create the Blog Listing page
Create .deco/blocks/pages-Blog-listing.json:
{
"__resolveType": "website/pages/Page.tsx",
"name": "Blog",
"path": "/blog",
"seo": {
"__resolveType": "website/sections/Seo/SeoV2.tsx",
"title": "Blog",
"description": "Latest articles and posts."
},
"sections": [
{
"__resolveType": "website/sections/Rendering/Lazy.tsx",
"section": {
"__resolveType": "Global Header"
}
},
{
"__resolveType": "site/sections/Blog/BlogPosts.tsx",
"title": "Blog",
"postsPerPage": 100,
"showMoreText": "Show more",
"posts": {
"count": 100,
"page": 1,
"__resolveType": "blog/loaders/BlogpostList.ts"
}
},
{
"__resolveType": "website/sections/Rendering/Lazy.tsx",
"section": {
"__resolveType": "Global Footer"
}
}
]
}
Step 6 — Create the Blog Post page
Create .deco/blocks/pages-blogpost.json:
{
"__resolveType": "website/pages/Page.tsx",
"name": "Blog Post Page",
"path": "/blog/:slug",
"seo": {
"__resolveType": "blog/sections/Seo/SeoBlogPost.tsx",
"jsonLD": {
"__resolveType": "blog/loaders/BlogPostPage.ts",
"slug": {
"__resolveType": "website/functions/requestToParam.ts",
"param": "slug"
}
}
},
"sections": [
{
"__resolveType": "website/sections/Rendering/Lazy.tsx",
"section": {
"__resolveType": "Global Header"
}
},
{
"__resolveType": "site/sections/Blog/BlogPost.tsx",
"page": {
"__resolveType": "blog/loaders/BlogPostPage.ts",
"slug": {
"__resolveType": "website/functions/requestToParam.ts",
"param": "slug"
}
}
},
{
"__resolveType": "website/sections/Rendering/Lazy.tsx",
"section": {
"__resolveType": "Global Footer"
}
}
]
}
Both the BlogPost section and SEO use
blog/loaders/BlogPostPage.ts. Blog Manager publishes posts directly to the blog block collection (collections/blog/posts/{slug}) — the loader picks them up automatically.
Step 7 — Create the Blog sections
These two sections must exist under sections/Blog/. Each site may already have them from a previous setup — check first. If they don't exist, create them based on the templates below.
sections/Blog/BlogPosts.tsx
Renders the post grid with optional category/search filtering and pagination.
import type { BlogPost } from "apps/blog/types.ts";
import Image from "apps/website/components/Image.tsx";
export interface Props {
/** @title Title */
title?: string;
/** @title Blog Posts */
posts?: BlogPost[];
/**
* @title Posts per page
* @default 9
*/
postsPerPage?: number;
/**
* @title Show More Text
* @default Show more
*/
showMoreText?: string;
/** @title Current page @hide */
page?: number;
/** @title Selected category @hide */
selectedCategory?: string;
/** @title Search term @hide */
search?: string;
/** @hide */
baseUrl?: string;
/** @hide */
hasMore?: boolean;
/** @hide */
totalPosts?: number;
}
export function loader(props: Props, req: Request) {
const url = new URL(req.url);
const page = Number(url.searchParams.get("page")) || 1;
const selectedCategory = url.searchParams.get("category") || undefined;
const search = url.searchParams.get("q") || undefined;
let filtered = props.posts || [];
if (selectedCategory) {
filtered = filtered.filter((p) => p.categories?.some((c) => c.slug === selectedCategory));
}
if (search) {
const q = search.toLowerCase();
filtered = filtered.filter(
(p) => p.title.toLowerCase().includes(q) || p.excerpt?.toLowerCase().includes(q),
);
}
const perPage = props.postsPerPage || 9;
const displayCount = page * perPage;
const visible = filtered.slice(0, displayCount);
return {
...props,
posts: visible,
page,
selectedCategory,
search,
hasMore: displayCount < filtered.length,
totalPosts: filtered.length,
baseUrl: req.url,
};
}
function getNextPageUrl(baseUrl?: string, page = 1) {
try {
const url = new URL(baseUrl || "");
url.searchParams.set("page", String(page + 1));
return `${url.pathname}${url.search}`;
} catch {
return `?page=${page + 1}`;
}
}
export default function BlogPosts({
title = "Blog",
posts = [],
page = 1,
hasMore,
baseUrl,
search,
totalPosts = 0,
showMoreText = "Show more",
}: Props) {
return (
<div class="w-full px-4 py-8">
{title && !search && (
<h1 class="text-4xl font-bold mb-8">{title}</h1>
)}
{search && (
<div class="mb-8">
<h1 class="text-4xl font-bold">Search results</h1>
<p class="text-gray-500 mt-1">{totalPosts} {totalPosts === 1 ? "result" : "results"} for "{search}"</p>
</div>
)}
{posts.length === 0
? (
<div class="text-center py-16">
<p class="text-gray-500 text-lg">No posts found.</p>
{search && <a href="/blog" class="text-blue-500 hover:underline text-sm mt-4 inline-block">Back to all posts</a>}
</div>
)
: (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
{posts.map((post, i) => (
<a key={i} href={`/blog/${post.slug}`} class="flex flex-col rounded-lg overflow-hidden border border-gray-200 hover:shadow-lg transition-shadow">
{post.image && (
<div class="aspect-video overflow-hidden">
<Image src={post.image} alt={post.alt || post.title} width={400} height={225} class="w-full h-full object-cover" />
</div>
)}
<div class="p-4 flex flex-col gap-2 flex-1">
{post.categories && post.categories.length > 0 && (
<div class="flex gap-1 flex-wrap">
{post.categories.slice(0, 2).map((cat, j) => (
<span key={j} class="text-xs font-medium px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">{cat.name}</span>
))}
</div>
)}
<h3 class="text-lg font-bold leading-tight">{post.title}</h3>
{post.excerpt && <p class="text-sm text-gray-500 line-clamp-2">{post.excerpt}</p>}
{post.date && <span class="text-xs text-gray-400 mt-auto pt-2">{new Date(post.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</span>}
</div>
</a>
))}
</div>
)}
{hasMore && (
<div class="flex justify-center py-8">
<a href={getNextPageUrl(baseUrl, page)} class="px-6 py-3 border border-black rounded font-medium hover:bg-black hover:text-white transition-colors">
{showMoreText}
</a>
</div>
)}
</div>
);
}
export function LoadingFallback() {
return (
<div class="w-full px-4 py-8">
<div class="h-10 w-48 bg-gray-100 rounded animate-pulse mb-8" />
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[...Array(6)].map((_, i) => (
<div key={i} class="rounded-lg overflow-hidden border border-gray-100 animate-pulse h-80 bg-gray-100" />
))}
</div>
</div>
);
}
sections/Blog/BlogPost.tsx
Renders the full blog post: hero, featured image, and rich content sections.
import type { BlogPostPage } from "apps/blog/types.ts";
import Image from "apps/website/components/Image.tsx";
import { renderSection } from "apps/website/pages/Page.tsx";
import { Section } from "@deco/deco/blocks";
import type { AppContext } from "site/apps/site.ts";
export interface Props {
/** @title Blog Post Page */
page?: BlogPostPage | null;
sectionsToRender?: Section[];
}
export const loader = async (props: Props, _req: Request, ctx: AppContext) => {
if (!props?.page?.post?.sections) return props;
const sectionsToRender = await Promise.all(
props.page.post.sections.map(async (section, index) =>
await ctx.get(section, {
resolveChain: [
{ value: ctx.resolverId ?? "root", type: "resolver" },
{ value: "idx" + index, type: "prop" },
],
})
),
);
return { ...props, sectionsToRender };
};
export default function BlogPost({ page, sectionsToRender }: Props) {
if (!page) {
return (
<div class="w-full py-20 text-center">
<h1 class="text-3xl font-bold text-gray-500">Post not found</h1>
<p class="text-gray-400 mt-4">The blog post you're looking for doesn't exist.</p>
</div>
);
}
return (
<div id="blog-post" class="w-full">
{/* Hero */}
<div class="bg-gray-50 py-12 md:py-16">
<div class="max-w-3xl mx-auto px-4">
<h1 class="text-4xl md:text-5xl font-bold leading-tight mb-4">{page.post.title}</h1>
{page.post.excerpt && <p class="text-lg text-gray-500 mb-6">{page.post.excerpt}</p>}
<div class="flex items-center gap-2 flex-wrap text-sm text-gray-400">
{page.post.authors?.map((author, i) => (
<span key={i}>{i > 0 && <span class="mx-1">|</span>}{author.name}</span>
))}
{page.post.date && (
<>
<span class="mx-1">|</span>
<span>{new Date(page.post.date).toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" })}</span>
</>
)}
</div>
</div>
</div>
{/* Featured Image */}
{page.post.image && (
<div class="max-w-4xl mx-auto px-4 -mt-6">
{page.post.image.startsWith("/")
? <img src={page.post.image} alt={page.post.alt || page.post.title} width={900} height={500} class="rounded-lg w-full h-auto" loading="lazy" />
: <Image src={page.post.image} alt={page.post.alt || page.post.title} width={900} height={500} class="rounded-lg w-full h-auto" />}
</div>
)}
{/* Content */}
<div class="max-w-3xl mx-auto px-4 py-10 md:py-16">
{Array.isArray(sectionsToRender) && sectionsToRender.map(renderSection)}
</div>
</div>
);
}
export function LoadingFallback() {
return (
<div class="w-full">
<div class="bg-gray-50 py-12 md:py-16">
<div class="max-w-3xl mx-auto px-4 space-y-4">
<div class="h-12 w-3/4 bg-gray-200 rounded animate-pulse" />
<div class="h-6 w-1/2 bg-gray-200 rounded animate-pulse" />
<div class="h-4 w-64 bg-gray-200 rounded animate-pulse" />
</div>
</div>
<div class="max-w-3xl mx-auto px-4 py-10 space-y-4">
{[...Array(5)].map((_, i) => (
<div key={i} class="h-4 bg-gray-100 rounded animate-pulse" style={{ width: `${75 + (i % 3) * 10}%` }} />
))}
</div>
</div>
);
}
Step 8 — Add a Blog link to the Header navbar
The header block is already known from Step 4. Now add a Blog navigation link to it so users can reach /blog from any page.
Find the header block file.
- The header
__resolveTypefound in Step 4 (e.g."Global Header") is the block name. - Locate its file under
.deco/blocks/— it is typically named something likeheader.json,global-header.json, or a similar slug derived from the block name. - If you are unsure, list
.deco/blocks/and open the file whose"name"field matches the header block name.
- The header
Inspect the navbar items.
- Inside the header block JSON, look for the array that holds navigation links. It is usually a field named
navItems,items,links,navigation, or similar — open the file and look for the array of objects that each contain alabel/nameand anhref/urlfield.
- Inside the header block JSON, look for the array that holds navigation links. It is usually a field named
Append the Blog entry.
- Add a new object to that array following the exact same shape as the existing items. Typically:
{ "label": "Blog", "href": "/blog" } - Match whatever field names (
label,name,text,title, etc.) and nesting the existing items use. Do not invent new fields.
- Add a new object to that array following the exact same shape as the existing items. Typically:
Save the file — no other changes are needed; the header section will pick up the new link automatically.
If the header block JSON is deeply nested or uses a resolver pattern (e.g.
"__resolveType": "..."for the items array), trace the resolver to find the correct place to add the link, and follow the same pattern used for existing items.
Step 9 — Connect Blog Manager in Studio
After the storefront pages are set up, connect the Blog Manager MCP agent in Studio.
- Open Studio → Settings → Virtual MCPs → + New
- Set the MCP server URL to:
https://spire-agent.infra.deco.cx/mcp - Give it a name like "Blog Manager" and save.
- Studio will discover 20 tools including
BLOG_MANAGER— it appears as a Pinned View tab automatically.
Then configure the Blog Manager settings (in Studio → Settings → Blog Manager):
- Domain — the site's domain (e.g.
mystore.com.br) - GitHub Owner — the org or user that owns the site's repo (e.g.
decocms) - GitHub Repo — the repo name (e.g.
mystore) - Language — content language (e.g.
pt-BRoren)
Then connect the required bindings in Studio → Settings → Connections:
| Binding | Required | Purpose |
|---|---|---|
| GitHub | Yes | Publishes posts to .deco/blocks/ in the site repo |
| Google Analytics 4 | Optional | Traffic and engagement data (ANALYTICS_REPORT) |
| Google Search Console | Optional | Rankings and impressions (ANALYTICS_OPPORTUNITIES) |
| SemRush | Optional | Keyword research (SEO_CONTENT_CLUSTER) |
| nanoBanana | Optional | AI cover image generation (POST_CREATE) |
Step 10 — Initialize brand context
Brand context is the mandatory first step before any content generation. The agent will not create posts until context is loaded.
- In Studio, open the Blog Manager agent — the BLOG_MANAGER Pinned View appears as a tab.
- Call BLOG_STATUS — it reports
context.hasContext: falseand points to the Context tab. - The UI redirects to the Context tab automatically when GitHub is connected but context is missing.
- Click "Build Brand Context" and enter the site URL — this scrapes the homepage, extracts brand identity with AI, and saves
brand/brand.mdandbrand/guardrails.mdto.deco/blog-manager/brand/in the site's GitHub repo. - Once complete, every session automatically loads context before generating any content.
Re-run BLOG_BUILD_CONTEXT (with
force: true) whenever there is a major brand or product category change.
Notes for customization
- Styles: The section templates above use plain Tailwind. Replace class names to match the site's design system (color tokens, font utilities, spacing helpers, etc.) once sections are created.
- SEO on the listing page: Fill in
titleanddescriptioninpages-Blog-listing.jsonwith the correct site-specific copy. postsPerPage: Defaults to100so all posts load client-side for filtering. Reduce this if the site has many posts and server-side pagination is preferred.- Posts storage: Blog Manager publishes posts to
.deco/blocks/collections%2Fblog%2Fposts%2F{slug}.jsonin the site's repo — they appear in the blog listing automatically viablog/loaders/BlogpostList.ts. - Library files: Brand context lives at
.deco/blog-manager/brand/brand.mdand.deco/blog-manager/brand/guardrails.md. Campaign data lives at.deco/blog-manager/campaigns/{weekId}/. - Campaign workflow: Use CAMPAIGN_BRIEF (keyword research, operator selects keywords) → CAMPAIGN_PLAN (ranked post ideas) → POST_OUTLINE → POST_CREATE → BLOG_PUBLISH_POST for the full 2-gate approval flow.