Building a Website with Notion Integration: Form Submission and Content Display

April 2, 2025 (1y ago) · 13 min read

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:

  1. Collecting form data from your website and storing it in a Notion database
  2. 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-app

Step 2: Install Required Dependencies#

npm install @notionhq/client notion-client notion-utils react-notion-x cloudinary

These 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#

  1. Go to https://www.notion.so/my-integrations
  2. Click "New integration"
  3. Name your integration (e.g., "My Website Integration")
  4. Select the capabilities your integration needs (Read & Write content)
  5. Click "Submit" to create your integration
  6. Copy the "Internal Integration Token" - this is your NOTION_TOKEN

Step 2: Create Form Submission Database#

  1. In Notion, create a new database
  2. Add columns that match your form fields (e.g., Name, Email, Company Name, etc.)
  3. Share the database with your integration:
  4. Copy the database ID from the URL (the part after the workspace name and before the question mark)

Step 3: Create Blog Content Database#

  1. Create another database for blog content
  2. Add columns like:
  3. Share this database with your integration too
  4. 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#

  1. Notion API Rate Limits
  2. Notion Page Rendering Issues
  3. Image Upload Failures

Security Best Practices#

  1. Always keep your Notion token and API keys in environment variables
  2. Validate and sanitize form inputs before submitting to Notion
  3. Implement rate limiting on your API endpoints
  4. Consider implementing authentication for your form submission page

Extending the Solution#

Here are some ways you can extend this solution:

  1. Comments System: Allow users to comment on blog posts
  2. Search Functionality: Add search to find specific blog posts
  3. Categories/Tags Filter: Add filtering by categories or tags
  4. Multi-language Support: Add support for multiple languages
  5. Analytics Integration: Track form submissions and blog views

Conclusion#

You've now learned how to implement two powerful Notion integrations:

  1. Collecting form data and storing it in Notion
  2. 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.