Originally published on LinkedIn, 2 April 2025. Republished here with light editing.
This comprehensive tutorial will guide you through implementing two powerful Notion integrations in your web application:
- Collecting form data from your website and storing it in a Notion database
- Displaying content from a Notion database as blog posts on your website
Whether you're a beginner or have some coding experience, this step-by-step guide will help you leverage Notion as both a data collection tool and a content management system.
Prerequisites#
- Basic knowledge of JavaScript and React
- A Next.js project (we'll be using Next.js 13+ with the App Router)
- A Notion account
- Node.js and npm installed on your computer
Section 1: Setting Up Your Project#
Step 1: Create a Next.js Project#
Start by creating a new Next.js project if you don't have one already:
npx create-next-app@latest my-notion-app
cd my-notion-appStep 2: Install Required Dependencies#
npm install @notionhq/client notion-client notion-utils react-notion-x cloudinaryThese packages include:
- @notionhq/client: Official Notion API client
- notion-client and notion-utils: Unofficial Notion clients for richer content rendering
- react-notion-x: React renderer for Notion pages
- cloudinary: For image uploading (optional but recommended)
Step 3: Set Up Environment Variables#
Create a .env.local file in your project root:
# Notion API credentials
NOTION_TOKEN=your_notion_api_token
NOTION_DATABASE_ID=your_form_database_id
# Notion blog configuration
NOTION_BLOG_DATABASE_ID=your_blog_database_id
# Cloudinary configuration (optional)
CLOUDINARY_CLOUD_NAME=your_cloud_name
CLOUDINARY_API_KEY=your_api_key
CLOUDINARY_API_SECRET=your_api_secret
Section 2: Setting Up Notion#
Step 1: Create a Notion Integration#
- Go to https://www.notion.so/my-integrations
- Click "New integration"
- Name your integration (e.g., "My Website Integration")
- Select the capabilities your integration needs (Read & Write content)
- Click "Submit" to create your integration
- Copy the "Internal Integration Token" - this is your NOTION_TOKEN
Step 2: Create Form Submission Database#
- In Notion, create a new database
- Add columns that match your form fields (e.g., Name, Email, Company Name, etc.)
- Share the database with your integration:
- Copy the database ID from the URL (the part after the workspace name and before the question mark)
Step 3: Create Blog Content Database#
- Create another database for blog content
- Add columns like:
- Share this database with your integration too
- Copy the database ID for your blog database
Section 3: Implementing Form Submission to Notion#
Step 1: Create the API Route for Form Submission#
Create a file at src/app/api/submit-ecosystem/route.js:
import { Client } from "@notionhq/client";
import { NextResponse } from "next/server";
// Initialize Notion client
const notion = new Client({
auth: process.env.NOTION_TOKEN,
});
export async function POST(request) {
try {
// Parse request body
const formData = await request.json();
// Get form fields
const {
name,
email,
companyName,
companyWebsite,
// Add all your form fields here
logo
} = formData;
// Create Notion page (database row)
await notion.pages.create({
parent: {
database_id: process.env.NOTION_DATABASE_ID,
},
properties: {
// Title field must be title type
Name: {
title: [
{
text: {
content: name,
},
},
],
},
Email: {
email: email,
},
"Company Name": {
rich_text: [
{
text: {
content: companyName,
},
},
],
},
// Add all your fields here, mapping to appropriate Notion property types
// For example:
"Company Website": {
url: companyWebsite,
},
// For image/logo (if you have a Files & media column in Notion)
"Logo": logo ? {
files: [
{
name: "Company Logo",
type: "external",
external: {
url: logo
}
}
]
} : {
files: []
},
},
});
// Return success response
return NextResponse.json({
success: true,
message: "Form data successfully submitted to Notion"
});
} catch (error) {
console.error("Error submitting to Notion:", error);
// Return error response
return NextResponse.json(
{
success: false,
message: "Submission failed",
error: error.message
},
{ status: 500 }
);
}
}Step 2: Create Image Upload API (Optional)#
If you need to handle image uploads, create a file at src/app/api/upload-image/route.js:
import { NextResponse } from "next/server";
import { v2 as cloudinary } from "cloudinary";
// Configure Cloudinary
cloudinary.config({
cloud_name: process.env.CLOUDINARY_CLOUD_NAME,
api_key: process.env.CLOUDINARY_API_KEY,
api_secret: process.env.CLOUDINARY_API_SECRET
});
export async function POST(request) {
try {
// Get Base64 image data from request body
const data = await request.json();
const base64Image = data.image;
if (!base64Image || !base64Image.startsWith('data:image')) {
return NextResponse.json(
{ success: false, message: "Invalid image data" },
{ status: 400 }
);
}
// Upload to Cloudinary
const uploadResult = await new Promise((resolve, reject) => {
cloudinary.uploader.upload(
base64Image,
{
folder: "your-folder-name",
resource_type: "image"
},
(error, result) => {
if (error) reject(error);
else resolve(result);
}
);
});
// Return image URL
return NextResponse.json({
success: true,
url: uploadResult.secure_url
});
} catch (error) {
console.error("Image upload failed:", error);
return NextResponse.json(
{ success: false, message: "Image upload failed", error: error.message },
{ status: 500 }
);
}
}Step 3: Create the Form Component#
Create a form component that collects user input and submits it to your API. Here's a simplified version:
"use client";
import React, { useState } from 'react';
export default function SubmissionForm() {
const [formData, setFormData] = useState({
name: '',
email: '',
companyName: '',
companyWebsite: '',
// Add all your form fields here
logo: null
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState(false);
// Handle input changes
const handleInputChange = (e) => {
const { name, value } = e.target;
setFormData((prev) => ({ ...prev, [name]: value }));
};
// Handle logo upload
const handleLogoUpload = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64Data = reader.result;
setFormData((prev) => ({ ...prev, logo: base64Data }));
};
reader.readAsDataURL(file);
}
};
// Handle form submission
const handleSubmit = async (e) => {
e.preventDefault();
setIsSubmitting(true);
try {
let finalFormData = {...formData};
// If there's a logo image (Base64 format), upload to Cloudinary first
if (formData.logo && formData.logo.startsWith('data:image')) {
try {
const imageResponse = await fetch('/api/upload-image', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ image: formData.logo }),
});
const imageResult = await imageResponse.json();
if (imageResult.success) {
finalFormData.logo = imageResult.url;
} else {
console.error('Image upload failed:', imageResult.message);
finalFormData.logo = null;
}
} catch (imageError) {
console.error('Image upload error:', imageError);
finalFormData.logo = null;
}
}
// Send form data to Notion API endpoint
const response = await fetch('/api/submit-ecosystem', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(finalFormData),
});
const result = await response.json();
if (result.success) {
setSubmitSuccess(true);
// Reset form
setFormData({
name: '',
email: '',
companyName: '',
companyWebsite: '',
// Reset all fields
logo: null
});
} else {
console.error('Form submission failed:', result.message);
alert('Submission failed. Please try again later.');
}
} catch (error) {
console.error('Error submitting form:', error);
alert('Submission failed. Please try again later.');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="form-container">
{submitSuccess ? (
<div className="success-message">
<h2>Thank you for your submission!</h2>
<p>We have received your information and will review it shortly.</p>
<button onClick={() => setSubmitSuccess(false)}>Submit Another</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="form-group">
<label htmlFor="name">Your Name *</label>
<input
type="text"
id="name"
name="name"
value={formData.name}
onChange={handleInputChange}
required
/>
</div>
<div className="form-group">
<label htmlFor="email">Email *</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleInputChange}
required
/>
</div>
{/* Add all your form fields here */}
<div className="form-group">
<label htmlFor="logo">Company Logo</label>
<input
type="file"
id="logo"
name="logo"
accept="image/*"
onChange={handleLogoUpload}
/>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Submitting...' : 'Submit'}
</button>
</form>
)}
</div>
);
}Section 4: Displaying Notion Content as Blog Posts#
Step 1: Create Notion Utility Functions#
Create a file at src/lib/notion.js to handle Notion data fetching:
import { Client } from '@notionhq/client';
import { NotionAPI } from 'notion-client';
import { getPageTitle } from 'notion-utils';
// Initialize official Notion client
const officialClient = new Client({
auth: process.env.NOTION_TOKEN,
});
// Initialize unofficial Notion client (for richer content rendering)
const notionClient = new NotionAPI();
/**
* Get blog page content
* @param {string} pageId Notion page ID
* @returns {Promise<Object>} Page content, title and record map
*/
export async function getPageContent(pageId) {
try {
// Clean up the ID format if needed
const cleanId = pageId.replace(/-/g, '');
// Try to fetch the page content
const recordMap = await notionClient.getPage(cleanId);
const title = getPageTitle(recordMap);
return { title, recordMap };
} catch (error) {
console.error('Failed to get Notion page:', error);
throw new Error(`Could not get Notion page "${pageId}": ${error.message}`);
}
}
/**
* Get all blog posts
* @param {string} databaseId Notion database ID
* @returns {Promise<Array>} Blog posts list
*/
export async function getAllBlogPosts(databaseId) {
try {
// Query the database using official API
const response = await officialClient.databases.query({
database_id: databaseId,
sorts: [
{
timestamp: "created_time",
direction: "descending",
},
],
});
// Process results
const posts = await Promise.all(
response.results.map(async (page) => {
// Get page ID
const id = page.id;
// Get page properties - prioritize Name field as title
const title = page.properties.Name?.title[0]?.plain_text ||
page.properties.Title?.title[0]?.plain_text ||
'Untitled';
const createdTime = new Date(page.created_time);
// Get cover image
let cover = null;
// 1. First check page cover
if (page.cover) {
if (page.cover.type === 'external') {
cover = page.cover.external.url;
} else if (page.cover.type === 'file') {
cover = page.cover.file.url;
}
}
// 2. If no page cover, check Cover property
if (!cover && page.properties.Cover) {
if (page.properties.Cover.type === 'files' && page.properties.Cover.files.length > 0) {
const firstFile = page.properties.Cover.files[0];
if (firstFile.type === 'external') {
cover = firstFile.external.url;
} else if (firstFile.type === 'file') {
cover = firstFile.file.url;
}
}
}
// Get excerpt (if any)
const excerpt = page.properties.Excerpt?.rich_text[0]?.plain_text ||
page.properties.Description?.rich_text[0]?.plain_text ||
'';
// Get tags
let tags = [];
if (page.properties.Tags && page.properties.Tags.type === 'multi_select') {
tags = page.properties.Tags.multi_select.map(tag => tag.name);
}
// Get status
let status = '';
if (page.properties.Status) {
if (page.properties.Status.type === 'status') {
status = page.properties.Status.status?.name || '';
} else if (page.properties.Status.type === 'select') {
status = page.properties.Status.select?.name || '';
}
}
return {
id,
title,
cover,
excerpt,
tags,
status,
createdAt: createdTime,
};
})
);
return posts;
} catch (error) {
console.error('Failed to get blog list:', error);
return [];
}
}
Step 2: Create a Blog Config File#
Create a file at src/lib/blog-config.js:
/**
* Blog configuration
*/
export const blogConfig = {
// Notion database ID
databaseId: process.env.NOTION_BLOG_DATABASE_ID,
// Default cover image
defaultCoverImage: '/images/default-blog-cover.jpg',
// Posts per page
postsPerPage: 9,
// Show author information
showAuthor: true,
};
// Additional Notion API config
export const notionApiConfig = {
// Use official API
useOfficialApi: true,
// Enable debug logs
debug: true,
};
Step 3: Create Blog List Page#
Create a file at src/app/blog/page.js:
import Link from 'next/link';
import Image from 'next/image';
import { getAllBlogPosts } from '../../lib/notion';
import { blogConfig } from '../../lib/blog-config';
// Disable cache, always get fresh content
export const revalidate = 0;
export default async function BlogPage() {
let blogPosts = [];
let error = null;
try {
// Get all blog posts
blogPosts = await getAllBlogPosts(blogConfig.databaseId);
} catch (err) {
console.error('Failed to get blog posts:', err);
error = err.message;
}
return (
<div className="container mx-auto px-4 py-8">
<header className="mb-8 text-center">
<h1 className="text-3xl font-bold mb-4">Blog</h1>
<p className="text-gray-600">Explore our latest articles and insights</p>
</header>
{error ? (
<div className="bg-red-50 p-4 rounded-lg text-center">
<h3 className="text-xl font-medium mb-2">Error retrieving blog content</h3>
<p className="text-gray-500">{error}</p>
</div>
) : blogPosts.length === 0 ? (
<div className="bg-gray-50 p-4 rounded-lg text-center">
<h3 className="text-xl font-medium mb-2">No Blog Posts</h3>
<p className="text-gray-500">Please add content in Notion</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{blogPosts.map(({ id, title, cover, excerpt, tags, createdAt }) => (
<Link key={id} href={`/blog/${id}`}>
<article className="bg-white rounded-lg overflow-hidden shadow-md hover:shadow-lg transition-shadow">
{/* Cover image */}
<div className="relative h-48">
{cover ? (
<Image
src={cover}
alt={title}
fill
className="object-cover"
/>
) : (
<div className="w-full h-full bg-gray-200 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-400">{title.charAt(0)}</span>
</div>
)}
</div>
{/* Content */}
<div className="p-4">
<h2 className="text-lg font-bold mb-2">{title}</h2>
{excerpt && (
<p className="text-gray-600 mb-3 line-clamp-2">{excerpt}</p>
)}
{/* Tags */}
{tags && tags.length > 0 && (
<div className="flex flex-wrap gap-2 mb-3">
{tags.slice(0, 3).map((tag, index) => (
<span key={index} className="text-xs px-2 py-1 bg-gray-100 text-gray-600">
#{tag}
</span>
))}
</div>
)}
{/* Date */}
<time className="text-xs text-gray-500">
{createdAt.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
</article>
</Link>
))}
</div>
)}
</div>
);
}
Step 4: Create Blog Detail Page Component#
Create a folder at src/app/blog/[blogId] and add a page.js file:
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { getPageContent } from '../../../lib/notion';
import NotionPage from '../../../components/notion/NotionPage';
// Disable cache
export const revalidate = 0;
export default async function BlogPostPage({ params }) {
const { blogId } = params;
if (!blogId) {
return notFound();
}
let pageContent;
let error = null;
try {
pageContent = await getPageContent(blogId);
} catch (err) {
console.error(`Failed to get blog content: ${err.message}`);
error = err;
}
return (
<div className="container mx-auto px-4 max-w-4xl py-8">
<div className="mb-4">
<Link href="/blog" className="text-blue-500 hover:text-blue-700">
← Back to Blog List
</Link>
</div>
{error ? (
<div className="text-center py-8">
<h3 className="text-xl font-medium mb-3">Error loading blog content</h3>
<p className="text-gray-500 mb-4">{error.message || 'Could not fetch requested blog content'}</p>
<Link href="/blog">
<button className="px-4 py-2 bg-blue-500 text-white rounded">
Back to Blog List
</button>
</Link>
</div>
) : pageContent && pageContent.recordMap ? (
<NotionPage
recordMap={pageContent.recordMap}
rootPageId={blogId}
title={pageContent.title || 'Blog Post'}
/>
) : (
<div className="animate-pulse space-y-4">
<div className="h-8 bg-gray-200 rounded w-3/4"></div>
<div className="h-4 bg-gray-200 rounded w-1/4"></div>
<div className="space-y-2 mt-6">
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-full"></div>
<div className="h-4 bg-gray-200 rounded w-3/4"></div>
</div>
</div>
)}
</div>
);
}
Step 5: Create the NotionPage Component#
Create a file at src/components/notion/NotionPage.jsx:
"use client";
import React, { useEffect, useState } from 'react';
import dynamic from 'next/dynamic';
import { NotionRenderer } from 'react-notion-x';
// Dynamically import components for better performance
const Code = dynamic(() =>
import('react-notion-x/build/third-party/code').then((m) => m.Code),
{ ssr: false }
);
const Collection = dynamic(() =>
import('react-notion-x/build/third-party/collection').then((m) => m.Collection),
{ ssr: false }
);
const Equation = dynamic(() =>
import('react-notion-x/build/third-party/equation').then((m) => m.Equation),
{ ssr: false }
);
const Pdf = dynamic(() =>
import('react-notion-x/build/third-party/pdf').then((m) => m.Pdf),
{ ssr: false }
);
const Modal = dynamic(() =>
import('react-notion-x/build/third-party/modal').then((m) => m.Modal),
{ ssr: false }
);
export default function NotionPage({
recordMap,
rootPageId,
title,
}) {
const [isClient, setIsClient] = useState(false);
// Only render on client-side
useEffect(() => {
setIsClient(true);
}, []);
if (!isClient) {
return null;
}
if (!recordMap) {
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<p className="text-red-500">Cannot load page content: recordMap is undefined</p>
</div>
);
}
return (
<article className="notion-content max-w-4xl mx-auto">
<header className="py-8 mb-8 border-b">
<h1 className="text-3xl font-bold mb-4">
{title}
</h1>
</header>
<NotionRenderer
recordMap={recordMap}
fullPage={false}
darkMode={false}
rootPageId={rootPageId}
components={{
Code,
Collection,
Equation,
Pdf,
Modal,
}}
/>
</article>
);
}
Section 5: Adding Styling and Finalizing the Project#
Step 1: Import Notion Styles#
Create a file at src/app/globals.css and add the following imports:
@import 'react-notion-x/src/styles.css';
@import 'prismjs/themes/prism-tomorrow.css';
@import 'katex/dist/katex.min.css';
/* Your other global styles */
Step 2: Optimize for Production#
Add CSS optimization to your next.config.mjs:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
domains: [
'www.notion.so',
'notion.so',
'images.unsplash.com',
'res.cloudinary.com', // For cloudinary images
],
},
};
export default nextConfig;
Troubleshooting and Best Practices#
Common Issues and Solutions#
- Notion API Rate Limits
- Notion Page Rendering Issues
- Image Upload Failures
Security Best Practices#
- Always keep your Notion token and API keys in environment variables
- Validate and sanitize form inputs before submitting to Notion
- Implement rate limiting on your API endpoints
- Consider implementing authentication for your form submission page
Extending the Solution#
Here are some ways you can extend this solution:
- Comments System: Allow users to comment on blog posts
- Search Functionality: Add search to find specific blog posts
- Categories/Tags Filter: Add filtering by categories or tags
- Multi-language Support: Add support for multiple languages
- Analytics Integration: Track form submissions and blog views
Conclusion#
You've now learned how to implement two powerful Notion integrations:
- Collecting form data and storing it in Notion
- Displaying content from Notion as a blog on your website
This approach gives you the benefits of a traditional CMS while leveraging Notion's user-friendly interface for content creation and data management. As your project grows, you can extend these integrations to meet your specific needs.
Happy coding!
Remember to replace placeholder values (your_notion_api_token, etc.) with your actual values. Also, customize the form fields and styling to match your specific project requirements.