Skip to content

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:

  1. Map Image URLs: Use the mapImageUrl prop in react-notion-x to intercept requests.
  2. 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.

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"
}
}
}

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.

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)

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:

  1. Use notion.blocks.retrieve({ block_id: blockId }).
  2. Extract the url from the response.
  3. Cache this URL in a server-side cache (like Redis) for 55 minutes to avoid hitting Notion API rate limits.

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" />
}}
/>
);
}
  • 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: 3600 in getStaticProps, your images will break for any user who visits at minute 61 before the page has background-regenerated. Always set revalidate to 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.retrieve for each image on every page load will trigger a 429 error. You must implement a caching layer or use the mapImageUrl to point to a permanent storage solution like Cloudinary.
  • Private Images: Ensure your NOTION_TOKEN is kept in .env.local and never exposed to the client-side. The image mapping should happen on the server or via a secure API proxy.