Cookbook: Blog with Next.js
Техническое руководство из исходного проекта Notty. Примеры, параметры и эксплуатационные ограничения.
Все редакцииОбновлено 2026-09-30
Build a full-stack blog with Notty CMS as the backend and Next.js as the frontend.
What You'll Build
- Blog with articles, categories, and tags
- Rich text content with cover images
- SEO metadata via reusable components
- Server-side rendering with ISR (Incremental Static Regeneration)
- Webhook-triggered revalidation on publish
Step 1: Define Schemas
Component: SEO Meta
// components/shared/seo-meta.json
{
"uid": "shared.seo-meta",
"category": "shared",
"info": { "displayName": "SEO Meta", "icon": "mdi:search-web" },
"attributes": {
"metaTitle": { "type": "string", "maxLength": 60 },
"metaDescription": { "type": "text", "maxLength": 160 },
"ogImage": { "type": "media", "allowedTypes": ["images"] }
}
}
Category
// schemas/category.json
{
"kind": "collectionType",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category"
},
"attributes": {
"name": { "type": "string", "required": true, "unique": true },
"slug": { "type": "string", "required": true, "unique": true },
"description": { "type": "text" },
"articles": {
"type": "relation",
"relation": "oneToMany",
"target": "article",
"mappedBy": "category"
}
}
}
Tag
// schemas/tag.json
{
"kind": "collectionType",
"info": {
"singularName": "tag",
"pluralName": "tags",
"displayName": "Tag"
},
"attributes": {
"name": { "type": "string", "required": true, "unique": true },
"slug": { "type": "string", "required": true, "unique": true },
"articles": {
"type": "relation",
"relation": "manyToMany",
"target": "article",
"mappedBy": "tags"
}
}
}
Article
// schemas/article.json
{
"kind": "collectionType",
"info": {
"singularName": "article",
"pluralName": "articles",
"displayName": "Article"
},
"options": {
"draftAndPublish": true,
"timestamps": true
},
"indexes": [{ "fields": ["slug"], "type": "unique" }],
"attributes": {
"title": { "type": "string", "required": true, "maxLength": 255 },
"slug": { "type": "string", "required": true, "unique": true },
"excerpt": { "type": "text", "maxLength": 300 },
"body": { "type": "richtext", "required": true },
"cover": { "type": "media", "allowedTypes": ["images"] },
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "category"
},
"tags": {
"type": "relation",
"relation": "manyToMany",
"target": "tag",
"inversedBy": "articles"
},
"seo": {
"type": "component",
"component": "shared.seo-meta"
}
}
}
Step 2: Create Content
Start the CMS and create some content via the admin panel or API:
# Create category
curl -X POST http://localhost:2102/api/content/category \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "Technology", "slug": "technology" }'
# Create tag
curl -X POST http://localhost:2102/api/content/tag \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{ "name": "TypeScript", "slug": "typescript" }'
# Create article
curl -X POST http://localhost:2102/api/content/article \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"title": "Getting Started with TypeScript",
"slug": "getting-started-typescript",
"excerpt": "A beginner-friendly introduction to TypeScript.",
"body": "<h2>What is TypeScript?</h2><p>TypeScript is a typed superset of JavaScript...</p>",
"category": 1,
"seo": {
"metaTitle": "Getting Started with TypeScript - My Blog",
"metaDescription": "Learn TypeScript from scratch with practical examples."
}
}'
# Publish the article
curl -X PUT http://localhost:2102/api/content/article/1/publish \
-H "Authorization: Bearer $TOKEN"
Step 3: Next.js Frontend
Create a Next.js Project
npx create-next-app@latest my-blog --typescript --tailwind --app
cd my-blog
API Client
// lib/notty.ts
const NOTTY_URL = process.env.NOTTY_URL || 'http://localhost:2102';
const NOTTY_TOKEN = process.env.NOTTY_TOKEN!;
interface NottyResponse<T> {
success: boolean;
data: T;
pagination?: {
total: number;
limit: number;
offset: number;
hasMore: boolean;
};
}
async function fetchNotty<T>(path: string): Promise<NottyResponse<T>> {
const res = await fetch(`${NOTTY_URL}${path}`, {
headers: { Authorization: `Bearer ${NOTTY_TOKEN}` },
next: { tags: ['notty'] },
});
if (!res.ok) throw new Error(`Notty API error: ${res.status}`);
return res.json();
}
export async function getArticles(limit = 10, offset = 0) {
return fetchNotty<Article[]>(
`/api/content/article?published=true&sort=created_at&sortOrder=desc&limit=${limit}&offset=${offset}&populate=category,tags,cover`
);
}
export async function getArticleBySlug(slug: string) {
const res = await fetchNotty<Article[]>(
`/api/content/article?filters[slug][$eq]=${slug}&published=true&populate=category,tags,cover`
);
return res.data[0] ?? null;
}
export async function getCategories() {
return fetchNotty<Category[]>('/api/content/category?sort=name&sortOrder=asc');
}
interface Article {
id: number;
title: string;
slug: string;
excerpt: string;
body: string;
cover?: { url: string; alt: string; formats: Record<string, { url: string }> };
category?: { id: number; name: string; slug: string };
tags?: Array<{ id: number; name: string; slug: string }>;
seo?: { metaTitle: string; metaDescription: string };
published_at: string;
created_at: string;
}
interface Category {
id: number;
name: string;
slug: string;
description: string;
}
Environment Variables
# .env.local
NOTTY_URL=http://localhost:2102
NOTTY_TOKEN=nk_your-api-token
Create an API token in the Notty admin panel (Settings → API Tokens) with content:read and media:read scopes.
Article List Page
// app/page.tsx
import { getArticles } from '@/lib/notty';
import Link from 'next/link';
export const revalidate = 60; // ISR: revalidate every 60 seconds
export default async function Home() {
const { data: articles } = await getArticles();
return (
<main className="max-w-3xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-8">
{articles.map((article) => (
<article key={article.id} className="border-b pb-8">
{article.cover && (
<img
src={`${process.env.NOTTY_URL}${article.cover.formats?.medium?.url || article.cover.url}`}
alt={article.cover.alt || article.title}
className="w-full h-48 object-cover rounded-lg mb-4"
/>
)}
<Link href={`/blog/${article.slug}`}>
<h2 className="text-2xl font-semibold hover:text-blue-600">{article.title}</h2>
</Link>
{article.excerpt && <p className="text-gray-600 mt-2">{article.excerpt}</p>}
<div className="flex gap-2 mt-3">
{article.category && (
<span className="text-sm bg-blue-100 text-blue-800 px-2 py-1 rounded">
{article.category.name}
</span>
)}
{article.tags?.map((tag) => (
<span key={tag.id} className="text-sm bg-gray-100 text-gray-700 px-2 py-1 rounded">
{tag.name}
</span>
))}
</div>
</article>
))}
</div>
</main>
);
}
Article Detail Page
// app/blog/[slug]/page.tsx
import { getArticleBySlug, getArticles } from '@/lib/notty';
import { notFound } from 'next/navigation';
import type { Metadata } from 'next';
interface Props {
params: Promise<{ slug: string }>;
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) return {};
return {
title: article.seo?.metaTitle || article.title,
description: article.seo?.metaDescription || article.excerpt,
};
}
export async function generateStaticParams() {
const { data: articles } = await getArticles(100);
return articles.map((a) => ({ slug: a.slug }));
}
export default async function ArticlePage({ params }: Props) {
const { slug } = await params;
const article = await getArticleBySlug(slug);
if (!article) notFound();
return (
<main className="max-w-3xl mx-auto py-12 px-4">
<h1 className="text-4xl font-bold mb-4">{article.title}</h1>
<time className="text-gray-500">{new Date(article.published_at).toLocaleDateString()}</time>
{article.cover && (
<img
src={`${process.env.NOTTY_URL}${article.cover.url}`}
alt={article.cover.alt || article.title}
className="w-full rounded-lg my-6"
/>
)}
<div
className="prose prose-lg max-w-none"
dangerouslySetInnerHTML={{ __html: article.body }}
/>
</main>
);
}
Step 4: Webhook Revalidation
Set up a webhook so Next.js revalidates when content is published.
Create a Revalidation API Route in Next.js
// app/api/revalidate/route.ts
import crypto from 'crypto';
import { revalidateTag } from 'next/cache';
import { NextRequest, NextResponse } from 'next/server';
const WEBHOOK_SECRET = process.env.NOTTY_WEBHOOK_SECRET!;
export async function POST(req: NextRequest) {
const body = await req.text();
const signature = req.headers.get('x-webhook-signature') || '';
// Verify webhook signature
const expected = crypto.createHmac('sha256', WEBHOOK_SECRET).update(body).digest('hex');
const signatureBuffer = Buffer.from(signature);
const expectedBuffer = Buffer.from(expected);
if (
signatureBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(signatureBuffer, expectedBuffer)
) {
return NextResponse.json({ error: 'Invalid signature' }, { status: 401 });
}
// Revalidate all Notty-tagged cache entries
revalidateTag('notty');
return NextResponse.json({ revalidated: true });
}
Register the Webhook in Notty
curl -X POST http://localhost:2102/api/webhooks \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"name": "Next.js Revalidation",
"url": "https://my-blog.vercel.app/api/revalidate",
"events": ["entry.publish", "entry.unpublish", "entry.update", "entry.delete"],
"content_types": ["article", "category", "tag"],
"secret": "whsec_your-webhook-secret"
}'
Now when you publish or update content in Notty, the blog automatically refreshes.
Summary
You now have:
- Content model — articles with categories, tags, cover images, and SEO metadata
- Headless API — Notty serves content via REST API with filtering and pagination
- Next.js frontend — server-rendered pages with ISR and static generation
- Auto-revalidation — webhooks trigger cache refresh on content changes
Источник: docs/cookbook/blog-nextjs.md. Снимок документации исходного проекта. Технический справочник сохраняет язык оригинала.