senternet-site-seo-blog
Add a prerendered SEO blog with posts and tag pages.
SKILL.md.claude/skills/senternet-site-seo-blog/SKILL.md
GitHubSEO Blog
Add an SEO-optimized blog section to drive organic search traffic. Each post gets its own prerendered route, sitemap entry, and share image.
Before you start
Ask the user:
What URL path should the blog live at? (e.g.
/blog,/insights,/news,/updates) Default is/blog— press enter to keep it.
Use their answer as $BLOG_PATH throughout. If they skip or enter nothing, use /blog.
Architecture
Posts live at $BLOG_PATH/SLUG. Each post is:
- A React component (or data-driven template) with unique content
- A prerendered static HTML page
- Listed on an index page at
/blog - Linked from a sitemap entry
- Given a programmatically generated share image
Steps
1. Create the blog index page
Name the component after the path segment (e.g. InsightsIndexPage for /insights, BlogIndexPage for /blog).
// src/components/BlogIndexPage.tsx ← rename to match $BLOG_PATH
import { MetaTags } from './MetaTags';
import { Link } from 'react-router-dom';
const POSTS = [
{
slug: 'how-to-get-started-with-appname',
title: 'How to Get Started with APPNAME',
date: '2026-01-15',
description: 'A step-by-step guide to setting up APPNAME and getting value on day one.',
image: '/share/post-getting-started.webp',
},
// Add new posts here
];
export function BlogIndexPage() {
return (
<>
<MetaTags
title="Blog - APPNAME"
description="Guides, tips, and updates from the APPNAME team."
image="/share/blog-index.webp"
/>
<main>
<h1>Blog</h1>
<ul>
{POSTS.map(post => (
<li key={post.slug}>
<Link to={`$BLOG_PATH/${post.slug}`}>
<h2>{post.title}</h2>
<time dateTime={post.date}>{post.date}</time>
<p>{post.description}</p>
</Link>
</li>
))}
</ul>
</main>
</>
);
}
2. Create individual post components
Each post is a standalone component:
// src/components/PostGettingStarted.tsx
import { MetaTags } from './MetaTags';
export function PostGettingStarted() {
return (
<>
<MetaTags
title="How to Get Started with APPNAME - APPNAME Blog"
description="A step-by-step guide to setting up APPNAME and getting value on day one."
image="/share/post-getting-started.webp"
/>
<article>
<header>
<h1>How to Get Started with APPNAME</h1>
<time dateTime="2026-01-15">January 15, 2026</time>
</header>
<p>Getting started with APPNAME takes less than five minutes...</p>
{/* Article body */}
{/* Schema.org Article markup */}
<script type="application/ld+json">{JSON.stringify({
"@context": "https://schema.org",
"@type": "Article",
"headline": "How to Get Started with APPNAME",
"datePublished": "2026-01-15",
"author": { "@type": "Organization", "name": "APPNAME" },
"image": "https://www.DOMAIN.com/share/post-getting-started.png",
})}</script>
</article>
</>
);
}
3. Add routes to src/App.tsx
<Route path="$BLOG_PATH" element={<BlogIndexPage />} />
<Route path="$BLOG_PATH/how-to-get-started-with-appname" element={<PostGettingStarted />} />
4. Add to scripts/prerender.mjs
const ROUTES = [
'$BLOG_PATH',
'$BLOG_PATH/how-to-get-started-with-appname',
];
5. Add to public/sitemap.xml
<url>
<loc>https://www.DOMAIN.com$BLOG_PATH</loc>
<lastmod>2026-01-15</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://www.DOMAIN.com$BLOG_PATH/how-to-get-started-with-appname</loc>
<lastmod>2026-01-15</lastmod>
<changefreq>monthly</changefreq>
<priority>0.7</priority>
</url>
Or add post routes to generate-sitemap.mjs and regenerate.
6. Generate a share image for each post
Add a block to scripts/generate-share-images.mjs:
const BLOG_POSTS = [
{ id: 'post-getting-started', title: 'How to Get Started with APPNAME', subtitle: 'January 15, 2026', color: '#00d4ff' },
];
for (const post of BLOG_POSTS) {
const svg = buildPostSvg(post); // your SVG template
await sharp(Buffer.from(svg)).png().toFile(`public/share/${post.id}.png`);
await sharp(`public/share/${post.id}.png`).webp({ quality: 85 }).toFile(`public/share/${post.id}.webp`);
}
7. Tag / category index pages (for programmatic SEO at scale)
For apps with many content categories, create index pages per tag:
$BLOG_PATH/topics → all topics
$BLOG_PATH/topics/guides → all guide posts
$BLOG_PATH/topics/updates → all product update posts
$BLOG_PATH/topics/tips → posts tagged with tips
Data lives in src/data/posts.ts:
export interface PostAppearance {
slug: string;
title: string;
date: string;
excerpt: string;
accentColor: string;
}
export interface Topic {
slug: string;
label: string;
posts: PostAppearance[];
}
export const TOPICS: Topic[] = [
{
slug: 'guides',
label: 'Guides',
posts: [
{
slug: 'how-to-get-started-with-appname',
title: 'How to Get Started with APPNAME',
date: '2026-01-15',
excerpt: 'Set up APPNAME and get value on day one.',
accentColor: '#00d4ff',
},
],
},
];
When adding a new post, update posts.ts for every topic it belongs to.
SEO writing guidelines
- Title format:
[Topic] - [Date or Context] | APPNAME— unique per post - Description: 150–160 chars, includes the primary keyword naturally
- H1: matches the post title exactly (one per page)
- Internal links: each post should link to 2–3 related posts and the blog index
- Publication date: use
<time dateTime="YYYY-MM-DD">for structured data - Alt text: all images need descriptive alt text, not filename
- Word count: 400+ words minimum for Google to consider it substantive content
- Freshness: update
<lastmod>in the sitemap when you revise a post
Auto-publishing and social announcement (the control plane)
Once the blog exists, a scheduled Claude Code routine can write each new post, ship it, prove it is live, and announce it on social with no manual step. This is the same control-plane pattern every Senternet property uses. Set it up per repo.
The invariant: announce a URL only after it is proven live. Not "deployed", not "the cron ran". Live. A single-page-app site answers HTTP 200 for a post URL before the page exists, serving the HOMEPAGE og:image and og:title; a scraper that lands there caches the wrong card against the permalink and keeps serving it long after the real page ships. Gate every announcement on scripts/verify-live.mjs <url> in the senternet control-plane repo (check it out as a second git source in the routine). It prints RELEASE-OK <url> only when the page returns 200, declares its own canonical + og:url, carries a fetchable absolute og:image, and is not the homepage card. Publish only the URL on that line; if it exits non-zero, publish nothing.
The routine flow (per site)
- Write the post (mirror the newest existing one), wire it in, build.
- Open a PR, then either merge it (squash) to trigger the deploy, or leave it for the owner to review and merge when the site wants a human gate.
- Run verify-live against the new URL until
RELEASE-OK. - Post to the brand's Buffer channels (rules below).
- Optionally amplify from a personal account (quote-tweet below).
Buffer posting rules
Reach Buffer over MCP; never put a Buffer token in a site repo. Several sites share one Buffer org, so the credential exists once, in the control plane, not once per repo.
Never create a post with status
scheduled. A scheduled post fires on wall-clock time whether or not the deploy landed. Usemode: 'shareNow'(only afterRELEASE-OK) for immediate posts. For a draft that a daily release routine publishes later, usesaveToDraft: truewithmode: 'customScheduled'and a futuredueAt(the intended publish time). Do not useaddToQueue(thecreate_postdefault): it silently dropsdueAt, and a draft with nodueAtis invisible to the release routine forever.Twitter / X: put the post URL in the
text. X has no link-attachment field.LinkedIn: attach the share image as a NATIVE IMAGE ASSET and put the URL in the
text. Do NOT usemetadata.linkedin.linkAttachment. LinkedIn does not auto-fetchog:imagefor API-posted shares the way Facebook does, so a link attachment renders a bare URL card with no image, even when the page's OG tags are perfect. Passing the image as a real asset makes it render, and native-image posts also outrank outbound-link posts in LinkedIn's feed. Use the PNG or JPG share image, not WebP (LinkedIn's image proxy is reliable on PNG/JPG and historically flaky on WebP).- This applies to EVERY LinkedIn channel in the run, with no exceptions — company pages AND personal profiles. The common failure is attaching the asset for one brand (e.g.
senternet) and shipping a sibling channel (e.g.beereadyinc,msenter) in the same batch withassets: []; that sibling renders a bare link. Build theassetsarray once and pass it to every LinkedIncreate_post; never send a LinkedIn post whoseassetsis empty.
create_post({ channelId: <linkedin channel id>, schedulingType: 'automatic', mode: 'shareNow', // or saveToDraft + customScheduled + future dueAt text: `${comment}\n\n${postUrl}`, // the URL is a plain clickable link assets: [{ image: { url: `https://www.DOMAIN.com/share/<share-id>.png`, // absolute, PNG/JPG, not WebP metadata: { altText: '<describe the share card>' }, } }], // NOTE: no metadata.linkedin.linkAttachment })- This applies to EVERY LinkedIn channel in the run, with no exceptions — company pages AND personal profiles. The common failure is attaching the asset for one brand (e.g.
Facebook:
metadata.facebook.type: 'post'+metadata.facebook.linkAttachment.urlworks; Facebook does unfurlog:image.Verify, do not assume.
create_post/edit_postmay hit a ~10s client timeout but still succeed server-side. Confirm withget_post(statussent/sending) before retrying, or you will double-post. For LinkedIn, also confirm the returned post'sassetsarray is non-empty — an emptyassetsmeans the card will render as a bare link and the image must be re-attached (asentLinkedIn post cannot have media edited afterward; delete and re-post to fix).
Cross-account amplification (optional)
To have a personal account quote-tweet a brand post on X: after the brand post is sent, read its externalLink (https://x.com/<id>/status/<TWEET_ID>), then create a post on the personal channel whose text is a short comment followed by https://x.com/<brand-handle>/status/<TWEET_ID>. Putting the tweet URL in the text is what makes X render a quote-tweet with commentary; Buffer's metadata.twitter.retweet field only does a plain retweet (no comment). Likes are not possible via Buffer at all: there is no like mutation.
Framework: Next.js track
Same content model, same SEO requirements, different route mechanics.
app/blog/page.tsx— the index.app/blog/[slug]/page.tsx— the post.app/blog/tag/[tag]/page.tsx— tag pages.- Export
generateStaticParams()from each dynamic route so every post and tag page is rendered at build time rather than per request. - Export
generateMetadata({ params })per post for title, description, canonical, and OG image. AddArticleJSON-LD in the post page itself. - Feed
app/sitemap.tsfrom the same post list thatgenerateStaticParamsuses — import it, do not maintain a parallel array. Post URLs then reach IndexNow automatically. - There is no prerender ROUTES array to extend, and adding a post does not require touching a build script.
- Publishing a post still requires a rollout, since the pages are built. If posts need to go live without a deploy, use
revalidateon the route (ISR) and say so explicitly rather than switching the pages to dynamic. - Share images: pre-generate per post into
public/share/blog/<slug>.pngwith the Sharp script and reference them fromgenerateMetadata.
See /senternet-site-framework for the full convention map.