Short version: I pulled my Medium RSS feed into my Next.js App Router, rendered posts server-side as real HTML pages (with proper metadata, canonical links and social cards), and got far better SEO discoverability than just linking to Medium. Below is a step-by-step tutorial that walks through the exact pieces I used (with code snippets), why they help SEO, and how to tweak them for your site.

Important: Medium does not officially support this method. They can always block it or change their output
Why this helps SEO (quick rationale)
- Server-side HTML: Crawlers love real HTML with metadata; rendering posts on your domain transfers authority and gives Google direct access to your content.
- Canonical + social meta: You control og: tags, structured data and <link rel="canonical"> so search engines know the canonical source and show rich previews.
- Internal linking & sitemap: Having posts on your domain lets you include them in your sitemap, internal link graph, and increases dwell time on your site.
- Faster Lighthouse/UX: You can optimize images, fonts, and markup on your own site — Lighthouse improvements help SEO indirectly.
What I did — file map
- lib/medium.ts — fetches and parses Medium RSS feed.
- app/blog/page.tsx — the blog index that lists posts (server-side).
- app/blog/[slug]/page.tsx — the individual post page with metadata, canonical link and rendered HTML.
- app/blog/[slug]/style.scss — styles for article content.
(These are the concrete files I used in my repo; I’ll walk you through their roles and show the key code snippets.)
Step 1 — Fetch and parse Medium feed (lib/medium.ts)
Goal: turn Medium RSS into a structured array of posts your Next.js app can consume. I fetch Medium’s RSS (e.g. https://medium.com/feed/@yourusername), parse it, and normalize fields (title, slug, date, content, image, canonicalLink).
Key ideas:
- Do this server-side (in async functions) so your blog index and post pages are rendered with content available to crawlers.
- Cache results or use revalidate if you want periodic updates rather than hitting Medium on every request.
Example (adapted from my repo):
// lib/medium.ts
import Parser from "rss-parser";
type MediumPost = {
title: string;
slug: string;
pubDate: string;
content: string; // HTML content from Medium
link: string; // original Medium URL
image?: string;
categories?: string[];
};
const parser = new Parser();
export async function fetchMediumPosts(): Promise<MediumPost[]> {
const feed = await parser.parseURL("https://medium.com/feed/@yourusername"); // Replace with your Meduim username
return feed.items.map(item => {
const title = item.title || "Blog";
// generate slug from title or from item.link
const slug = (item.link || title).split("/").pop()?.split("?")[0] || title.replace(/\s+/g, "-").toLowerCase();
return {
title,
slug,
pubDate: item.pubDate || "",
content: item["content:encoded"] || item.content || "",
link: item.link || "",
image: (item.enclosure && item.enclosure.url) || undefined,
categories: item.categories || []
};
});
}
Why this matters for SEO: you get the full HTML content (content:encoded) to render on your domain, and you can pull the image for og:image.
Step 2 — Create a server-side blog index (app/blog/page.tsx)
Goal: render a list of posts on /blog with SSR so search engines index all posts and their summaries.
Key ideas:
- Use an async server component (App Router) that calls fetchMediumPosts() and returns HTML cards for each post.
- Include schema/meta on the page and ensure each post link points to /blog/[slug] on your domain.
Example (adapted from my repo):
// app/blog/page.tsx (Next.js /app route server component)
import Link from "next/link";
import { fetchMediumPosts } from "@/lib/medium";
export default async function BlogPage() {
const posts = await fetchMediumPosts();
return (
<main>
<h1>Blog</h1>
<ul>
{posts.map(post => (
<li key={post.slug}>
<article>
<h2>
<Link href={`/blog/${post.slug}`}>{post.title}</Link>
</h2>
<time dateTime={post.pubDate}>{new Date(post.pubDate).toLocaleDateString()}</time>
<p dangerouslySetInnerHTML={{ __html: post.content.slice(0, 300) + "..." }} />
<Link href={`/blog/${post.slug}`}>Read more →</Link>
</article>
</li>
))}
</ul>
</main>
);
}
SEO tip: render an accessible summary (first 150–300 chars) and include the publish date — both are used in search snippets.
Step 3 — Render individual posts with strong metadata (app/blog/[slug]/page.tsx)
Goal: make each post a full HTML page on your domain with:
- <title> and meta description,
- og: and Twitter card tags,
- <link rel="canonical"> pointing to Medium or to your URL (decide based on where you want canonical authority),
- structured data (Article schema) if you want rich results.
Important decision: canonical — if you prefer Medium to remain canonical, set canonical to Medium link; if you want crawlers to index your copy as canonical (and avoid duplicate content issues), set canonical to your /blog/[slug]. In my case I set canonical to the Medium original to be safe, but still got SEO benefit from having the content on my site and proper open graph meta. (Pick what fits your goals.)
Example server component (adapted):
// app/blog/[slug]/page.tsx
import { fetchMediumPosts } from "@/lib/medium";
import Head from "next/head";
type Props = { params: { slug: string } };
export default async function PostPage({ params }: Props) {
const posts = await fetchMediumPosts();
const post = posts.find(p => p.slug === params.slug);
if (!post) {
return <p>Post not found</p>;
}
return (
<>
<Head>
<title>{post.title} - My Name</title>
<meta name="description" content={`${post.title} - read on my site`} />
<link rel="canonical" href={post.link} />
<meta property="og:title" content={post.title} />
<meta property="og:description" content={`${post.title} - read on my site`} />
{post.image && <meta property="og:image" content={post.image} />}
<meta property="og:type" content="article" />
<meta property="article:published_time" content={post.pubDate} />
</Head>
<article className="article">
<h1>{post.title}</h1>
<time dateTime={post.pubDate}>{new Date(post.pubDate).toLocaleDateString()}</time>
<div className="content" dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
</>
);
}
Notes:
- dangerouslySetInnerHTML is used to render the Medium HTML. Before deploying, sanitize or whitelist any dangerous tags if you’re concerned.
- Optionally add structured data JSON-LD inside <Head> for Article schema to increase chances for rich results.
Step 4 — Style the article (app/blog/[slug]/style.scss)
Goal: ensure readable typography and responsive images (so Lighthouse score stays high), and make the content respect your site’s dark mode.
Key style points:
- Set max-width for article body, responsive images (max-width: 100%), pre/code styling, and typographic rhythm.
- Constrain large images (so they don’t blow up layout) and lazy-load them where possible.
Example (adapted):
// app/blog/[slug]/style.scss
.article {
max-width: 760px;
margin: 0 auto;
line-height: 1.6;
font-size: 18px;
img {
max-width: 100%;
height: auto;
display: block;
margin: 1rem 0;
}
pre {
overflow: auto;
padding: 1rem;
border-radius: 8px;
}
h1, h2, h3 {
margin-top: 1.6rem;
margin-bottom: 0.6rem;
}
}
Small styling wins = big Lighthouse gains (image sizing, font-display, avoiding huge layout shifts).
Step 5 — Sitemap and internal linking
Make sure your /blog/[slug] URLs are present in your sitemap.xml. In Next.js you can generate a sitemap that reads the posts (via fetchMediumPosts()) and emits URLs with lastmod.
Also:
- Add structured internal links (e.g., related posts, tags) to help crawlers discover and rank your content.
- Add rel="nofollow" only where needed (sponsored/affiliate links), keep editorial links followable.
SEO snippet (sitemap generator pseudo):
// app/sitemap.ts
import { MetadataRoute } from "next";
import { fetchMediumPosts } from "@/lib/medium";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const baseUrl = "https://alifarooqi.com"; // Replace with your domain
const mediumPosts = await fetchMediumPosts();
const blogUrls = mediumPosts.map(post => ({
url: `${baseUrl}/blog/${post.slug}`,
lastModified: post.pubDate ? new Date(post.pubDate) : new Date(),
}));
return [
{
url: baseUrl,
lastModified: new Date(),
},
{
url: `${baseUrl}/blog`,
lastModified: new Date(),
},
...blogUrls,
];
}
Optional: cache & update strategy
- Static at build time: If you deploy on Vercel and your Medium voice doesn’t change often, fetch at build time (faster).
- Incremental revalidation: Use Next.js revalidate / ISR so new Medium posts appear after a fixed interval.
- On-demand revalidation: After you publish on Medium, call an API to revalidate the Next.js page(s) so your site refreshes instantly.
Pitfalls & gotchas
- Duplicate content: If Medium and your site both appear canonical for the same article, Google may choose one. Choose canonical intentionally.
- Images/Media: Medium-hosted image URLs can be large; consider copying important images to your CDN for performance.
- Sanitization: If you render raw HTML, ensure you sanitize or carefully trust the feed to avoid XSS.
- Rate limit / feed changes: Medium feed may throttle; cache results and handle errors gracefully.
Results I saw (qualitative)
- Better indexed posts on my domain (search queries showing my domain rather than just Medium).
- Improved preview cards on LinkedIn/Twitter since I control og: meta.
- Lighthouse scores improved after optimizing article styles and image sizing on my domain.
Final checklist before you deploy
- Choose canonical strategy (your site vs Medium).
- Add structured data (Article JSON-LD).
- Add og:image, twitter:card and proper title/meta description per post.
- Generate sitemap with /blog/* entries.
- Test with Google’s Rich Results/URL Inspection and run Lighthouse.
- Optionally rehost critical images on your CDN.