diff --git a/examples/with-medium-rss-blog/.env.example b/examples/with-medium-rss-blog/.env.example new file mode 100644 index 000000000000..e1c209f28e50 --- /dev/null +++ b/examples/with-medium-rss-blog/.env.example @@ -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="" \ No newline at end of file diff --git a/examples/with-medium-rss-blog/.gitignore b/examples/with-medium-rss-blog/.gitignore new file mode 100644 index 000000000000..84e50a01f4e8 --- /dev/null +++ b/examples/with-medium-rss-blog/.gitignore @@ -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 \ No newline at end of file diff --git a/examples/with-medium-rss-blog/README.md b/examples/with-medium-rss-blog/README.md new file mode 100644 index 000000000000..0fa50c04db9b --- /dev/null +++ b/examples/with-medium-rss-blog/README.md @@ -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/@` 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. \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/blog/[slug]/page.tsx b/examples/with-medium-rss-blog/app/blog/[slug]/page.tsx new file mode 100644 index 000000000000..78c37b641ff3 --- /dev/null +++ b/examples/with-medium-rss-blog/app/blog/[slug]/page.tsx @@ -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 ( +
+
+

{post.title}

+

+ {formatDate(post.date)} +

+
+ + {/* dangerouslySetInnerHTML is safe here: the HTML is sanitized through + DOMPurify with an explicit tag + attribute allowlist before render. */} +
+
+ ); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/blog/page.tsx b/examples/with-medium-rss-blog/app/blog/page.tsx new file mode 100644 index 000000000000..1d2209b70543 --- /dev/null +++ b/examples/with-medium-rss-blog/app/blog/page.tsx @@ -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 ( +
+

Blog

+

+ Posts fetched from a Medium RSS feed. +

+ + {posts.length === 0 ? ( +

+ No posts yet. Set MEDIUM_USERNAME in{" "} + .env.local to your Medium handle. +

+ ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/blog/utils.ts b/examples/with-medium-rss-blog/app/blog/utils.ts new file mode 100644 index 000000000000..00687a5c7e97 --- /dev/null +++ b/examples/with-medium-rss-blog/app/blog/utils.ts @@ -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", + }); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/globals.css b/examples/with-medium-rss-blog/app/globals.css new file mode 100644 index 000000000000..83a0fa37a1ca --- /dev/null +++ b/examples/with-medium-rss-blog/app/globals.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + color-scheme: light dark; +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/layout.tsx b/examples/with-medium-rss-blog/app/layout.tsx new file mode 100644 index 000000000000..3bccc3d9a6b7 --- /dev/null +++ b/examples/with-medium-rss-blog/app/layout.tsx @@ -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 ( + + +
{children}
+ + + ); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/app/page.tsx b/examples/with-medium-rss-blog/app/page.tsx new file mode 100644 index 000000000000..1328c86b3b30 --- /dev/null +++ b/examples/with-medium-rss-blog/app/page.tsx @@ -0,0 +1,25 @@ +import Link from "next/link"; + +export default function Home() { + return ( +
+

+ Medium RSS Blog Example +

+

+ This example renders a /blog page from a Medium RSS feed. + Set MEDIUM_USERNAME in .env.local to your + Medium handle (without the @) and the route will fetch and + cache your posts. Leave it empty to render an empty list. +

+

+ + View the blog → + +

+
+ ); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/lib/medium-feed.json b/examples/with-medium-rss-blog/lib/medium-feed.json new file mode 100644 index 000000000000..fc69ce2b19dd --- /dev/null +++ b/examples/with-medium-rss-blog/lib/medium-feed.json @@ -0,0 +1,3 @@ +{ + "items": [] +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/lib/medium.ts b/examples/with-medium-rss-blog/lib/medium.ts new file mode 100644 index 000000000000..9ab51376061c --- /dev/null +++ b/examples/with-medium-rss-blog/lib/medium.ts @@ -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 { + if (!MY_USERNAME) { + return parseMediumFeed( + mediumFeed as unknown as Parser.Output, + ); + } + + 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, + ); + } +} + +export function parseMediumFeed( + feed: Parser.Output, +): 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(/]+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); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/lib/sanitize.ts b/examples/with-medium-rss-blog/lib/sanitize.ts new file mode 100644 index 000000000000..9f4f3097d260 --- /dev/null +++ b/examples/with-medium-rss-blog/lib/sanitize.ts @@ -0,0 +1,62 @@ +import DOMPurify from "isomorphic-dompurify"; + +// Allowlist derived from the actual content of a typical Medium RSS feed: +// structural tags, headings, lists, inline formatting, code, links, and +// images. Anything else (scripts, event handlers, style attrs) is stripped. +const ALLOWED_TAGS = [ + // Structural + "p", + "blockquote", + "hr", + "figure", + "figcaption", + // Headings + "h2", + "h3", + "h4", + // Lists + "ul", + "ol", + "li", + // Inline formatting + "em", + "strong", + "code", + // Code blocks + "pre", + // Links + media + "a", + "img", +]; + +// Array form (not the per-tag object form) — DOMPurify's TS types only +// accept this. Functionally equivalent since is the only allowlisted +// tag that takes width/height. +const ALLOWED_ATTR = ["href", "src", "alt", "width", "height"]; + +// Negative lookahead: allow anything EXCEPT dangerous schemes. DOMPurify v3 +// applies ALLOWED_URI_REGEXP to ALL attribute values (not just href/src), so +// a positive pattern like `^https?:` would also strip width/height/title text. +const ALLOWED_URI_REGEXP = /^(?!(?:javascript|data|vbscript|file):)/i; + +// DOMPurify v3 special-cases data:image/* and re-adds it even when +// ALLOWED_URI_REGEXP would reject. Close that hole with an explicit hook. +DOMPurify.addHook("afterSanitizeAttributes", (node) => { + if (node && node.nodeType === 1 && (node as Element).tagName === "IMG") { + const el = node as Element; + const src = el.getAttribute("src"); + if (src && /^data:/i.test(src)) { + el.removeAttribute("src"); + } + } +}); + +export function sanitizeMediumHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS, + ALLOWED_ATTR, + ALLOW_DATA_ATTR: false, + ALLOWED_URI_REGEXP, + FORBID_ATTR: ["style", "class", "title"], + }); +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/next.config.mjs b/examples/with-medium-rss-blog/next.config.mjs new file mode 100644 index 000000000000..a58e23930312 --- /dev/null +++ b/examples/with-medium-rss-blog/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; \ No newline at end of file diff --git a/examples/with-medium-rss-blog/package.json b/examples/with-medium-rss-blog/package.json new file mode 100644 index 000000000000..b418619c7e4b --- /dev/null +++ b/examples/with-medium-rss-blog/package.json @@ -0,0 +1,25 @@ +{ + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start" + }, + "dependencies": { + "isomorphic-dompurify": "^2.16.0", + "next": "latest", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "rss-parser": "^3.13.0" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@types/node": "^20.11.17", + "@types/react": "^18.3.3", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.4" + } +} \ No newline at end of file diff --git a/examples/with-medium-rss-blog/postcss.config.mjs b/examples/with-medium-rss-blog/postcss.config.mjs new file mode 100644 index 000000000000..f4e0b50bb812 --- /dev/null +++ b/examples/with-medium-rss-blog/postcss.config.mjs @@ -0,0 +1,10 @@ +// If you want to use other PostCSS plugins, see the following: +// https://tailwindcss.com/docs/using-with-preprocessors +const config = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; + +export default config; \ No newline at end of file diff --git a/examples/with-medium-rss-blog/tailwind.config.mjs b/examples/with-medium-rss-blog/tailwind.config.mjs new file mode 100644 index 000000000000..15695bd10d38 --- /dev/null +++ b/examples/with-medium-rss-blog/tailwind.config.mjs @@ -0,0 +1,11 @@ +import typography from "@tailwindcss/typography"; + +/** @type {import('tailwindcss').Config} */ +const config = { + content: ["./app/**/*.{js,ts,jsx,tsx,mdx}"], + darkMode: "class", + theme: { extend: {} }, + plugins: [typography], +}; + +export default config; diff --git a/examples/with-medium-rss-blog/tsconfig.json b/examples/with-medium-rss-blog/tsconfig.json new file mode 100644 index 000000000000..04458c2612df --- /dev/null +++ b/examples/with-medium-rss-blog/tsconfig.json @@ -0,0 +1,43 @@ +{ + "compilerOptions": { + "target": "es2017", + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "baseUrl": ".", + "paths": { + "@/*": [ + "./*" + ] + }, + "incremental": true, + "plugins": [ + { + "name": "next" + } + ] + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts", + ".next/dev/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}