Разделы документации
ОбзорБыстрый стартРедакции и возможностиМодели и поляРедактор контентаМедиатекаЛокализацияПубликация и работа командыAPI, SDK и генерация типовРасширения и инструментыРабочие проектыЗадания, вебхуки и наблюдаемостьАудит и управление даннымиСоветники и доверие к плагинамКорпоративный входПространства, квоты и масштабированиеCommerce и PortalПрава и безопасностьРазвёртывание и обновленияЛицензии и установка пакетовТекущие ограниченияПомощь и диагностикаДанные в кабинетеCore CMSDeveloper PlatformProduction UseWorkflowOperationsComplianceAI AssistantsPlugin TrustEnterprise IdentityEnterprise ScaleEnterprise DeploymentCommerce BundlePortal BundleNotty CMS DocumentationAuth & SecurityContent ModelingDeploymentEcosystem & Packaging ConventionsEditions and First-party ModulesExtensibilityGetting StartedMedia ManagementModule Extraction PathDraft & PublishUpgrade GuideWebhooks & IntegrationsCookbook: Blog with Next.jsCookbook: Custom PluginCookbook: Multilingual SiteOperations DocsBackup AutomationDeployment BlueprintsRunbook — Восстановление БД из бэкапаRunbook — Плановый деплойRunbook — Реакция на инцидентRunbook — Откат релизаRunbook — Горизонтальное масштабированиеRunbook — Ротация секретовRunbook — Major upgradeSecrets ManagementNotty CMS — Capability MapNotty Configuration ModelGenerated App ContractComponents and Dynamic ZonesMiddleware SystemPerformance & Scaling ToolkitDisaster Recovery PlaybookDistribution Model
Документация / Технический справочник

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:

  1. Content model — articles with categories, tags, cover images, and SEO metadata
  2. Headless API — Notty serves content via REST API with filtering and pagination
  3. Next.js frontend — server-rendered pages with ISR and static generation
  4. Auto-revalidation — webhooks trigger cache refresh on content changes

Источник: docs/cookbook/blog-nextjs.md. Снимок документации исходного проекта. Технический справочник сохраняет язык оригинала.