Next.js Integration

Learn how to integrate NotCMS with Next.js App Router to build a fully-featured blog.

Setup

1. Create Next.js Project

npx create-next-app@latest my-blog --typescript --app
cd my-blog

2. Install NotCMS

npm install notcms
npm install -D notcms-kit

3. Initialize NotCMS

npx notcms-kit init

4. Configure Next.js

Update next.config.js:
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.notcms.com',
      }
    ]
  }
}

module.exports = nextConfig

Project Structure

my-blog/
├── app/
│   ├── page.tsx           # Blog listing
│   └── [id]/
│       └── page.tsx       # Blog post
├── components/
│   ├── BlogCard.tsx
│   ├── BlogPost.tsx
│   └── Pagination.tsx
└── src/
    └── notcms/
        └── schema.ts      # Generated schema

Blog Listing Page

app/page.tsx

import { Client } from 'notcms';
import { schema } from '@/notcms/schema';
import BlogCard from '@/components/BlogCard';

const nc = new Client({
  secretKey: process.env.NOTCMS_SECRET_KEY,
  workspaceId: process.env.NOTCMS_WORKSPACE_ID,
});

export default async function BlogPage() {
  const [posts] = await nc.query.blog.list();

  return (
    <div className="container mx-auto px-4 py-8">
      <h1 className="text-4xl font-bold mb-8">Blog</h1>
      
      <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
        {posts.map((post) => (
          <BlogCard key={post.id} post={post} />
        ))}
      </div>
    </div>
  );
}

// Revalidate every 10 minutes
export const revalidate = 600;

Blog Post Page

app/[id]/page.tsx

import { notFound } from 'next/navigation';
import { Client } from 'notcms';
import { schema } from '@/notcms/schema';
import BlogPost from '@/components/BlogPost';
import type { Metadata } from 'next';

interface PageProps {
  params: {
    id: string;
  };
}

const nc = new Client({
  secretKey: process.env.NOTCMS_SECRET_KEY,
  workspaceId: process.env.NOTCMS_WORKSPACE_ID,
});

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const [post] = await nc.query.blog.get(params.id);
  
  if (!post) {
    return {
      title: 'Post Not Found',
    };
  }
  
  return {
    title: post.title,
    description: post.excerpt,
    openGraph: {
      title: post.title,
      description: post.excerpt,
      images: post.coverImage ? [post.coverImage] : [],
    },
  };
}

export default async function BlogPostPage({ params }: PageProps) {
  const [post] = await nc.query.blog.get(params.id);
  
  if (!post) {
    notFound();
  }
  
  return <BlogPost post={post} />;
}

// Revalidate every 10 minutes
export const revalidate = 600;

Components

components/BlogCard.tsx

import Link from 'next/link';
import Image from 'next/image';
import { Client } from 'notcms';
import { schema } from '@/notcms/schema';

const nc = new Client({
  schema,
});

type BlogPosts = typeof schema.blog.pages.$inferPages;
type BlogPost = BlogPosts[number];
// BlogPost: {
//   id: string;
//   title: string;
//   properties: {
//     coverImage: string;
//     publishDate: string;
//     authors: string[];
//     tags: string[];
//   }
// }
// NOTE: Above is just an example. The actual schema will reflect YOUR database schema.

interface BlogCardProps {
  post: BlogPost;
}

export default function BlogCard({ post }: BlogCardProps) {
  return (
    <article className="border rounded-lg overflow-hidden shadow-sm hover:shadow-md transition-shadow">
      {post.properties.coverImage && (
        <Link href={`/blog/${post.id}`}>
          <Image
            src={post.properties.coverImage}
            alt={post.properties.title}
            width={400}
            height={250}
            className="w-full h-48 object-cover"
          />
        </Link>
      )}
      
      <div className="p-4">
        <Link href={`/blog/${post.id}`}>
          <h2 className="text-xl font-semibold mb-2 hover:text-blue-600">
            {post.properties.title}
          </h2>
        </Link>
        
        <div className="flex items-center justify-between text-sm text-gray-500">
          <span>{post.properties.publishDate}</span>
        </div>
        
        {post.properties.tags.length > 0 && (
          <div className="mt-3 flex gap-2 flex-wrap">
            {post.properties.tags.map((tag) => (
              <span
                key={tag}
                className="px-2 py-1 bg-gray-100 rounded-md text-xs"
              >
                {tag}
              </span>
            ))}
          </div>
        )}
      </div>
    </article>
  );
}

components/BlogPost.tsx

import Image from 'next/image';
import { RichText } from '@/components/RichText';
import { Client } from 'notcms';
import { schema } from '@/notcms/schema';

const nc = new Client({
  schema,
});

type BlogPost = typeof schema.blog.pages.$inferPage;
// BlogPost: {
//   id: string;
//   title: string;
//   properties: {
//     coverImage: string;
//     publishDate: string;
//     authors: string[];
//     tags: string[];
//   }
//   content: string; // Markdown
// }
// NOTE: Above is just an example. The actual schema will reflect YOUR database schema.

interface BlogPostProps {
  post: BlogPost;
}

export default function BlogPost({ post }: BlogPostProps) {
  return (
    <article className="max-w-4xl mx-auto px-4 py-8">
      {post.properties.coverImage && (
        <Image
          src={post.properties.coverImage}
          alt={post.properties.title}
          width={1200}
          height={600}
          className="w-full h-96 object-cover rounded-lg mb-8"
        />
      )}
      
      <header className="mb-8">
        <h1 className="text-4xl font-bold mb-4">{post.properties.title}</h1>
        
        <div className="flex items-center gap-4 text-gray-600">
          <time>{post.properties.publishDate}</time>
        </div>
        
        {post.properties.tags.length > 0 && (
          <div className="mt-4 flex gap-2 flex-wrap">
            {post.properties.tags.map((tag) => (
              <span
                key={tag}
                className="px-3 py-1 bg-blue-100 text-blue-800 rounded-full text-sm"
              >
                {tag}
              </span>
            ))}
          </div>
        )}
      </header>
      
      <div className="prose prose-lg max-w-none">
        <RichText content={post.properties.content} />
      </div>
    </article>
  );
}

Rich Text Rendering

components/RichText.tsx

Convert the content to HTML using a markdown parser as you like.
import { marked } from 'marked';

interface RichTextProps {
  content: string;
}

export function RichText({ content }: RichTextProps) {
  if (!content) return null;
  return <div dangerouslySetInnerHTML={{ __html: marked(content) }} />;
}

Performance Optimization

Static Generation

// Generate static pages at build time
export async function generateStaticParams() {
  const [posts] = await nc.query.blog.list();

  return posts.map((post) => ({
    id: post.id,
  }));
}

Image Optimization

import Image from 'next/image';

// Use Next.js Image component
<Image
  src={post.properties.coverImage}
  alt={post.properties.title}
  width={1200}
  height={600}
  priority={isAboveFold}
  placeholder="blur"
  blurDataURL={post.properties.coverImage}
/>
Don’t forget to add the domain to the next.config.js file.
/** @type {import('next').NextConfig} */
const nextConfig = {
  images: {
    remotePatterns: [
      {
        protocol: 'https',
        hostname: '*.notcms.com',
      }
    ]
  }
}

module.exports = nextConfig

SEO Optimization

Metadata API

export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
  const [post] = await nc.query.blog.get(params.id);
  
  return {
    title: post.title,
    description: post.properties.excerpt,
    authors: [{ name: post.properties.authors.join(', ') }],
    openGraph: {
      title: post.title,
      description: post.properties.excerpt,
      type: 'article',
      publishedTime: post.properties.publishDate,
      authors: [post.properties.authors.join(', ')],
      images: [
        {
          url: post.properties.coverImage,
          width: 1200,
          height: 600,
          alt: post.properties.title,
        }
      ],
    },
    twitter: {
      card: 'summary_large_image',
      title: post.title,
      description: post.properties.excerpt,
      images: [post.properties.coverImage],
    },
  };
}

Deployment

Environment Variables

Set these in your deployment platform:
NOTCMS_SECRET_KEY=your-secret-key
NOTCMS_WORKSPACE_ID=your-workspace-id

Next Steps