Build a blog with Next.js and NotCMS
npx create-next-app@latest my-blog --typescript --app
cd my-blog
npm install notcms
npm install -D notcms-kit
npx notcms-kit init
next.config.js
:
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.notcms.com',
}
]
}
}
module.exports = nextConfig
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
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;
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/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>
);
}
components/RichText.tsx
import { marked } from 'marked';
interface RichTextProps {
content: string;
}
export function RichText({ content }: RichTextProps) {
if (!content) return null;
return <div dangerouslySetInnerHTML={{ __html: marked(content) }} />;
}
// Generate static pages at build time
export async function generateStaticParams() {
const [posts] = await nc.query.blog.list();
return posts.map((post) => ({
id: post.id,
}));
}
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}
/>
next.config.js
file.
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: '*.notcms.com',
}
]
}
}
module.exports = nextConfig
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],
},
};
}
NOTCMS_SECRET_KEY=your-secret-key
NOTCMS_WORKSPACE_ID=your-workspace-id