SEO That Actually Works for Dev Blogs
"The best place to hide a dead body is page two of Google search results." — Anonymous search-marketing saying
Most SEO advice for dev blogs is generic: "write good content," "use keywords," "get backlinks." This post covers the technical SEO implementations that made a measurable difference for this site.
hreflang Strategy
The site targets English-speaking audiences in India (en-IN) and globally (en). The hreflang implementation uses three variants:
// Root layout (src/app/layout.tsx)
export const metadata: Metadata = {
alternates: {
languages: {
'en': '/blog',
'en-IN': '/blog',
'x-default': '/blog',
},
},
};Every page that defines its own alternates includes the same three variants:
// Per-page metadata
alternates: {
canonical: canonicalUrl,
languages: {
'en': canonicalUrl,
'en-IN': canonicalUrl,
'x-default': canonicalUrl,
},
},The en-IN variant signals Google that the content is relevant to Indian users (who are the primary audience). The x-default variant catches all other language/region combinations. Without x-default, Google might not serve the page to users in unsupported regions.
Dynamic OG Image Generation
The OG image endpoint at /blog/api/og generates a share card dynamically using Next.js edge rendering:
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const slug = searchParams.get("slug");
const title = searchParams.get("title");
const post = slug ? await getPostBySlug(slug) : null;
const displayTitle = post?.title ?? title ?? "Madhu Dadi — AI, Python & Analytics Hub";
return new ImageResponse(
<div style={{ /* OG card layout */ }}>
<div style={{ fontSize: 48, fontWeight: 700, color: "white" }}>
{displayTitle}
</div>
<div style={{ fontSize: 24, color: "#f59e0b" }}>
Madhu Dadi — AI, Python & Analytics Hub
</div>
</div>,
{ width: 1200, height: 630 }
);
}The endpoint generates:
- Post pages —
og?slug=post-slug— renders the post title + site name - Series pages —
og?series=series-slug— renders the series title + part count - Tag pages —
og?title=tag-name&tags=tag— renders the tag name - Generic —
og— renders the default site title
Sitemap Structure
The sitemap at /blog/sitemap.xml includes all published posts and series:
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const posts = await postsApi.list({ limit: 1000, status: "published" });
const series = await seriesApi.list({ limit: 100 });
const postEntries = posts.items.map((post) => ({
url: `${SITE_URL}/blog/posts/${post.slug}`,
lastModified: post.updated_at ?? post.published_at,
changeFrequency: "monthly" as const,
priority: 0.8,
}));
const seriesEntries = series.items.map((s) => ({
url: `${SITE_URL}/blog/series/${s.slug}`,
lastModified: s.updated_at ?? s.created_at,
changeFrequency: "weekly" as const,
priority: 0.6,
}));
return [
{ url: `${SITE_URL}/blog`, changeFrequency: "daily", priority: 1.0 },
{ url: `${SITE_URL}/blog/posts`, changeFrequency: "daily", priority: 0.9 },
{ url: `${SITE_URL}/blog/series`, changeFrequency: "weekly", priority: 0.7 },
{ url: `${SITE_URL}/blog/tags`, changeFrequency: "weekly", priority: 0.5 },
{ url: `${SITE_URL}/blog/about`, changeFrequency: "monthly", priority: 0.3 },
...postEntries,
...seriesEntries,
];
}The sitemap is regenerated daily by the background scheduler and submitted to Google via the Indexing API.
robots.txt for AI Crawlers
The robots.txt explicitly welcomes AI crawlers while protecting user data:
User-agent: *
Allow: /blog/api/og
Allow: /blog
Disallow: /blog/admin
Disallow: /blog/profile
Disallow: /blog/bookmarks
Disallow: /blog/api
Disallow: /blog/authThe OG image allow must come before the /blog/api disallow. Social media crawlers (LinkedIn, Facebook, Twitter) check robots.txt before fetching the OG image — if /blog/api is disallowed without an explicit allow for /blog/api/og, the share card won't render.
For AI crawlers, the permissions are broader:
User-agent: GPTBot
User-agent: ClaudeBot
User-agent: PerplexityBot
...
Allow: /blog
Allow: /blog/posts
Allow: /blog/series
Allow: /blog/ask
Allow: /blog/api/og
Allow: /blog/llms.txt
Allow: /blog/ai-profile.json
Disallow: /blog/adminCanonical URLs
Every page defines a canonical URL to prevent duplicate content issues:
const canonicalUrl = `${SITE_URL}/blog/posts/${slug}`;
return {
alternates: {
canonical: canonicalUrl,
languages: { ... },
},
};The canonical URL is always the https:// version with the correct path. No trailing slash, no www prefix, no query parameters (except for paginated list pages).
Structured Data Validation
Every post page is validated against Google's Rich Results Test. The five schema blocks (TechArticle, FAQPage, HowTo, BreadcrumbList, Course) are tested individually:
// Validated output (TechArticle)
{
"@type": "TechArticle",
"headline": "Building a RAG Chat System From Zero",
"description": "How the Ask AI page works...",
"teaches": ["Embedding Pipeline", "HNSW Index", "Hybrid Search"],
"educationalLevel": "Advanced",
"timeRequired": "PT20M",
"wordCount": 3200,
}Key validation rules:
headlinemust be the clean title without brand suffixesdescriptionmust be under 160 charactersteachesshould contain 3-10 specific topics (not generic tags)educationalLevelmust be capitalized (Beginner, Intermediate, Advanced)@typemust be a single type, not an array
PageSpeed Optimizations
Three optimizations that improved Lighthouse scores:
1. Font Display Swap
const inter = Inter({
subsets: ["latin"],
variable: "--font-inter",
display: "swap",
preload: true,
});display: "swap" ensures text is visible immediately with a fallback font while the custom font loads. Without this, Lighthouse flags "Ensure text remains visible during webfont load."
2. Preconnect to External Origins
<link rel="preconnect" href="https://www.googletagmanager.com" />
<link rel="dns-prefetch" href="https://www.googletagmanager.com" />Preconnecting to Google Tag Manager and Google Analytics shaves ~200ms off the initial connection time.
3. Optimized Image Loading
images: {
remotePatterns: [
{ protocol: "https", hostname: "madhudadi.in" },
],
},All images are served with Next.js Image Optimization, which automatically generates WebP versions, sets appropriate sizes, and adds lazy loading.
What These Changes Achieved
| Optimization | Before | After | Impact |
|---|---|---|---|
| hreflang | None | en + en-IN + x-default | Better regional targeting |
| OG images | Missing on 3 pages | All pages | Share card renders everywhere |
| Structured data | 2 schema types | 5 schema types | Rich results eligibility |
| robots.txt | Blocked OG endpoint | Explicit allow | Share card on social media |
| Canonical URLs | Inconsistent | Uniform | No duplicate content issues |
| PageSpeed | ~65 mobile | ~92 mobile | Better ranking signal |
What's Next
The final post in this series — the honest retrospective: what I'd do differently, what I'd do the same, the numbers (build time, cost, traffic), and what broke in production.
Built with Next.js 16, structured data, and zero third-party SEO plugins.
Next in this series: Final Review of My Full Build: Insights and Key Changes →
