Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions examples/with-medium-rss-blog/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# Medium username (without the @) whose RSS feed powers /blog.
# Leave empty to render an empty blog list (the example will use the
# empty fallback snapshot in lib/medium-feed.json).
MEDIUM_USERNAME=""
38 changes: 38 additions & 0 deletions examples/with-medium-rss-blog/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.

# dependencies
/node_modules
/.pnp
.pnp.js

# testing
/coverage

# next.js
/.next/
/out/
next-env.d.ts

# production
build

# misc
.DS_Store
*.pem

# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local

# vercel
.vercel

# typescript
*.tsbuildinfo
56 changes: 56 additions & 0 deletions examples/with-medium-rss-blog/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Medium RSS Blog Example

This example shows how to render a Next.js App Router blog backed by a user's [Medium RSS feed](https://help.medium.com/hc/en-us/articles/214874118-RSS-feeds), sanitized for safe HTML rendering and cached with ISR.

Most blog examples in this repo use local Markdown or MDX files. This one is different — the post content lives on Medium, so you don't have to maintain a separate Markdown copy in your repo.

## Features

- **Live fetch from Medium** — `lib/medium.ts` parses the feed at `https://medium.com/feed/@<username>` using [`rss-parser`](https://github.com/rbren/rss-parser).
- **Committed fallback snapshot** — `lib/medium-feed.json` is used when `MEDIUM_USERNAME` is empty or the network request fails, so the page never crashes on a fresh clone.
- **HTML sanitization** — `lib/sanitize.ts` runs the feed content through `isomorphic-dompurify` with an explicit tag + attribute allowlist before rendering.
- **ISR caching** — both `/blog` and `/blog/[slug]` declare `revalidate = 43200` (12 hours), so Vercel only re-fetches the feed twice a day.
- **Static params** — `generateStaticParams` pre-renders one page per published post at build time.

## Deploy your own

[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-medium-rss-blog&project-name=with-medium-rss-blog&repository-name=with-medium-rss-blog)

## How to use

Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example:

```bash
npx create-next-app --example with-medium-rss-blog with-medium-rss-blog-app
```

```bash
yarn create next-app --example with-medium-rss-blog with-medium-rss-blog-app
```

```bash
pnpm create next-app --example with-medium-rss-blog with-medium-rss-blog-app
```

## Configuration

Copy `.env.example` to `.env.local` and set your Medium handle (without the `@`):

```bash
cp .env.example .env.local
```

```bash
# .env.local
MEDIUM_USERNAME="your-handle"
```

Leave it empty to render an empty blog list (the example will use the committed fallback snapshot in `lib/medium-feed.json`).

Deploy it to the cloud with [Vercel](https://vercel.com/new?utm_source=github&utm_medium=readme&utm_campaign=next-example) ([Documentation](https://nextjs.org/docs/app/building-your-application/deploying)).

## Notes

- The sanitizer uses a tag allowlist (no `script`, no event handlers, no inline styles). Any HTML outside the allowlist is stripped — see the `ALLOWED_TAGS` / `ALLOWED_ATTR` constants in [`lib/sanitize.ts`](./lib/sanitize.ts).
- Medium's RSS feed exposes the full post HTML under `content:encoded`. We fall back to `content` when that's absent.
- The route is statically generated via `generateStaticParams`. After a deploy, new Medium posts won't appear until the next revalidation cycle.
65 changes: 65 additions & 0 deletions examples/with-medium-rss-blog/app/blog/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { notFound } from "next/navigation";
import { getMediumPost, getMediumPosts } from "@/lib/medium";
import { sanitizeMediumHtml } from "@/lib/sanitize";
import { formatDate } from "../utils";

// Match the index route's revalidate window so a single deploy refreshes
// both the list and individual posts in lockstep.
export const revalidate = 43200;

export async function generateStaticParams() {
const posts = await getMediumPosts();
return posts.map((post) => ({ slug: post.slug }));
}

export async function generateMetadata({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getMediumPost(slug);
if (!post) return {};

return {
title: post.title,
description: post.summary,
openGraph: {
title: post.title,
description: post.summary,
type: "article",
publishedTime: post.date,
},
};
}

export default async function BlogPost({
params,
}: {
params: Promise<{ slug: string }>;
}) {
const { slug } = await params;
const post = await getMediumPost(slug);

if (!post) notFound();

return (
<article>
<header className="mb-8">
<h1 className="text-4xl font-bold tracking-tight">{post.title}</h1>
<p className="mt-2 text-sm text-neutral-500 dark:text-neutral-400">
{formatDate(post.date)}
</p>
</header>

{/* dangerouslySetInnerHTML is safe here: the HTML is sanitized through
DOMPurify with an explicit tag + attribute allowlist before render. */}
<div
className="prose max-w-none dark:prose-invert"
dangerouslySetInnerHTML={{
__html: sanitizeMediumHtml(post.content ?? ""),
}}
/>
</article>
);
}
50 changes: 50 additions & 0 deletions examples/with-medium-rss-blog/app/blog/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Link from "next/link";
import { getMediumPosts } from "@/lib/medium";
import { formatDate } from "./utils";

// 12 hours. ISR is the actual caching layer — without it, the route would
// re-fetch the Medium feed on every visit. Adjust to taste.
export const revalidate = 43200;

export default async function BlogIndex() {
const posts = await getMediumPosts();

return (
<div>
<h1 className="mb-2 text-3xl font-bold tracking-tight">Blog</h1>
<p className="mb-8 text-neutral-600 dark:text-neutral-400">
Posts fetched from a Medium RSS feed.
</p>

{posts.length === 0 ? (
<p className="text-neutral-600 dark:text-neutral-400">
No posts yet. Set <code>MEDIUM_USERNAME</code> in{" "}
<code>.env.local</code> to your Medium handle.
</p>
) : (
<ul className="divide-y divide-neutral-200 dark:divide-neutral-800">
{posts.map((post) => (
<li key={post.slug} className="py-6">
<Link
href={`/blog/${post.slug}`}
className="block hover:opacity-80"
>
<h2 className="text-xl font-semibold tracking-tight">
{post.title}
</h2>
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
{formatDate(post.date)}
</p>
{post.summary && (
<p className="mt-2 text-neutral-700 dark:text-neutral-300">
{post.summary}
</p>
)}
</Link>
</li>
))}
</ul>
)}
</div>
);
}
12 changes: 12 additions & 0 deletions examples/with-medium-rss-blog/app/blog/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Formats an ISO date string as `Mon DD, YYYY`. Stable across server and
// client by hardcoding the locale and timezone so the rendered HTML matches
// what the browser would otherwise produce locally.
export function formatDate(iso?: string): string {
if (!iso) return "";
return new Date(iso).toLocaleDateString("en-US", {
year: "numeric",
month: "short",
day: "numeric",
timeZone: "UTC",
});
}
7 changes: 7 additions & 0 deletions examples/with-medium-rss-blog/app/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

body {
color-scheme: light dark;
}
19 changes: 19 additions & 0 deletions examples/with-medium-rss-blog/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import "./globals.css";
import type { Metadata } from "next";
import type { ReactNode } from "react";

export const metadata: Metadata = {
title: "Medium RSS Blog Example",
description:
"A Next.js App Router example showing how to render a blog backed by a Medium RSS feed.",
};

export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="min-h-screen bg-white text-neutral-900 antialiased dark:bg-neutral-950 dark:text-neutral-100">
<main className="mx-auto max-w-3xl px-4 py-12">{children}</main>
</body>
</html>
);
}
25 changes: 25 additions & 0 deletions examples/with-medium-rss-blog/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import Link from "next/link";

export default function Home() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">
Medium RSS Blog Example
</h1>
<p className="text-neutral-600 dark:text-neutral-400">
This example renders a <code>/blog</code> page from a Medium RSS feed.
Set <code>MEDIUM_USERNAME</code> in <code>.env.local</code> to your
Medium handle (without the <code>@</code>) and the route will fetch and
cache your posts. Leave it empty to render an empty list.
</p>
<p>
<Link
href="/blog"
className="text-blue-600 underline-offset-4 hover:underline dark:text-blue-400"
>
View the blog →
</Link>
</p>
</div>
);
}
3 changes: 3 additions & 0 deletions examples/with-medium-rss-blog/lib/medium-feed.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"items": []
}
75 changes: 75 additions & 0 deletions examples/with-medium-rss-blog/lib/medium.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Parser from "rss-parser";
import mediumFeed from "./medium-feed.json";

// Undefined is treated the same as empty string — see the !MY_USERNAME
// guard below. Trim in case the env var was pasted with surrounding
// whitespace; without this we'd silently build a malformed feed URL.
const MY_USERNAME = process.env.MEDIUM_USERNAME?.trim();

export type MediumPost = Partial<{
title: string;
link: string;
content: string; // full HTML content
date: string;
slug: string;
summary: string;
image: string;
}>;

type MediumFeedItem = {
title?: string;
link?: string;
content?: string;
"content:encoded"?: string;
isoDate?: string;
};

// Fetches the user's Medium RSS feed and normalizes each entry. On any
// error (network, missing username, malformed XML) falls back to the
// committed snapshot so the page never crashes.
export async function getMediumPosts(): Promise<MediumPost[]> {
if (!MY_USERNAME) {
return parseMediumFeed(
mediumFeed as unknown as Parser.Output<MediumFeedItem>,
);
}

const parser = new Parser();
try {
const feed = await parser.parseURL(
`https://medium.com/feed/@${MY_USERNAME}`,
);
return parseMediumFeed(feed);
} catch (error) {
console.error("Error fetching Medium posts:", error);
return parseMediumFeed(
mediumFeed as unknown as Parser.Output<MediumFeedItem>,
);
}
}

export function parseMediumFeed(
feed: Parser.Output<MediumFeedItem>,
): MediumPost[] {
return feed.items.map((item) => {
const content = item["content:encoded"] || item.content || ""; // full HTML
const summary =
content.replace(/<[^>]+>/g, " ").slice(0, 160) + "..."; // plain text summary
const image = content.match(/<img[^>]+src="([^">]+)"/)?.[1];
return {
title: item.title,
link: item.link,
content,
summary,
image,
date: item.isoDate,
// Medium uses GUID slugs — last path segment of the canonical URL.
slug: item.link?.split("?")[0]?.split("/").pop(),
};
});
}

export async function getMediumPost(slug: string) {
const posts = await getMediumPosts();
return posts.find((p) => p.slug === slug);
}
Loading