Fix Notion Media Expiration in Next.js react-notion-x
The Advanced Use-Case: High-Performance Headless Notion
Section titled “The Advanced Use-Case: High-Performance Headless Notion”Developers building portfolio sites, documentation, or blogs using Next.js and react-notion-x often encounter a frustrating behavior: images and videos appear perfectly during development or immediately after a build, but “break” (return 403 or 404 errors) a few hours later.
This happens because you are treating Notion as a static CMS, but Notion treats its media assets as dynamic, ephemeral resources. When you fetch data via the notion-api, the URLs provided for files uploaded directly to Notion are AWS S3 “Signed URLs.”
The “Aha!” Solution: Dynamic URL Refreshing
Section titled “The “Aha!” Solution: Dynamic URL Refreshing”The inconsistency isn’t a CSS or rendering bug—it is a security feature of the Notion API. All internal Notion file URLs expire after exactly one hour.
To solve this, you cannot rely on Static Site Generation (SSG) alone. You must implement a two-pronged strategy:
- Map Image URLs: Use the
mapImageUrlprop inreact-notion-xto intercept requests. - API Proxying: Create a Next.js API route that fetches a “fresh” signed URL from the Notion API whenever a component mounts or a session initiates, or use an image proxy (like Cloudinary or a dedicated S3 bucket) to persist the media.
Step-by-Step Implementation
Section titled “Step-by-Step Implementation”1. Analyze the Notion API Response
Section titled “1. Analyze the Notion API Response”When you query a page or block, the JSON response for a media component looks like this. Note the expiry_time.
JSON Data Structure: Notion File Object
{ "type": "video", "video": { "type": "file", "file": { "url": "https://s3.us-west-2.amazonaws.com/secure.notion-static.com/...", "expiry_time": "2023-10-27T14:30:00.000Z" } }}2. Configure the Notion Integration
Section titled “2. Configure the Notion Integration”Go to Settings & Members > Connections > Develop or manage integrations. Create an Internal Integration and ensure it has Read content permissions. Share the specific database or page with this integration.
3. Implement the Custom Map Function
Section titled “3. Implement the Custom Map Function”In your Next.js page component, you must pass a function to the NotionRenderer that handles the URL transformation.
| Strategy | Implementation Complexity | Performance |
|---|---|---|
| Direct S3 | Low | Low (Links expire hourly) |
| Proxy API | Medium | Medium (Refreshing overhead) |
| CDN Sync | High | High (Permanent URLs) |
4. The Next.js API Route for Fresh Links
Section titled “4. The Next.js API Route for Fresh Links”Create a file at /pages/api/notion-asset.js. This route will take a blockId and return the latest signed URL.
JSON Data Structure: API Request
{ "blockId": "550e8400-e29b-41d4-a716-446655440000", "assetType": "image"}Implementation Logic:
- Use
notion.blocks.retrieve({ block_id: blockId }). - Extract the
urlfrom the response. - Cache this URL in a server-side cache (like Redis) for 55 minutes to avoid hitting Notion API rate limits.
5. Connecting the Frontend
Section titled “5. Connecting the Frontend”Update your renderer to use the fresh data:
import { NotionRenderer } from 'react-notion-x';
const mapImageUrl = (url, block) => { // If it's an external link (Unsplash/Direct), return as is if (!url.includes('secure.notion-static.com')) return url;
// For internal files, point to your proxy or refresh logic return `/api/notion-asset?blockId=${block.id}`;};
export default function NotionPage({ recordMap }) { return ( <NotionRenderer recordMap={recordMap} mapImageUrl={mapImageUrl} components={{ // Use standard HTML5 video or a custom player for consistency video: (props) => <video {...props} controls className="notion-video" /> }} /> );}Edge Cases & Limitations
Section titled “Edge Cases & Limitations”- Video Autoplay/Streaming: Notion’s S3 links don’t always support Range requests perfectly in all browsers. If videos fail to scrub (fast-forward), consider hosting the video on YouTube/Vimeo and embedding the link in Notion rather than uploading the file.
- Incremental Static Regeneration (ISR): If you use
revalidate: 3600ingetStaticProps, your images will break for any user who visits at minute 61 before the page has background-regenerated. Always setrevalidateto a value lower than 3600 (e.g., 1800). - Rate Limiting: The Notion API allows 3 requests per second. If your page has 50+ images, calling
blocks.retrievefor each image on every page load will trigger a 429 error. You must implement a caching layer or use themapImageUrlto point to a permanent storage solution like Cloudinary. - Private Images: Ensure your
NOTION_TOKENis kept in.env.localand never exposed to the client-side. The image mapping should happen on the server or via a secure API proxy.