Astro กับ Contentful: Content Management

#astro13 เม.ย. 2569

Astro กับ Contentful: Content Management

Contentful เป็น headless CMS ระดับ enterprise ที่ได้รับความไว้วางใจจากบริษัทชั้นนำทั่วโลก ด้วย Content Delivery API ที่รวดเร็ว, rich content modeling, และ localization support ทำให้ Contentful เหมาะสำหรับ websites ขนาดใหญ่ที่ต้องการ content management ที่ครบครัน

ทำไมต้องใช้ Contentful?

  • Enterprise-grade: ใช้งานโดยบริษัทชั้นนำ
  • Rich Content Modeling: กำหนด content types ได้อย่างยืดหยุ่น
  • Localization: รองรับหลายภาษาในตัว
  • Webhooks: แจ้งเตือนเมื่อ content เปลี่ยนแปลง
  • GraphQL API: รองรับทั้ง REST และ GraphQL

การติดตั้ง

npm install contentful contentful-management

ตั้งค่า environment variables:

# .env
CONTENTFUL_SPACE_ID=your-space-id
CONTENTFUL_ACCESS_TOKEN=your-access-token
CONTENTFUL_PREVIEW_TOKEN=your-preview-token
CONTENTFUL_MANAGEMENT_TOKEN=your-management-token

สร้าง Contentful Client

// src/lib/contentful.ts
import contentful from 'contentful';

export const contentfulClient = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: import.meta.env.CONTENTFUL_ACCESS_TOKEN,
});

export const previewClient = contentful.createClient({
  space: import.meta.env.CONTENTFUL_SPACE_ID,
  accessToken: import.meta.env.CONTENTFUL_PREVIEW_TOKEN,
  host: 'preview.contentful.com',
});

// TypeScript types สำหรับ content
export interface BlogPost {
  contentTypeId: 'blogPost';
  fields: {
    title: contentful.EntryFieldTypes.Text;
    slug: contentful.EntryFieldTypes.Text;
    excerpt: contentful.EntryFieldTypes.Text;
    content: contentful.EntryFieldTypes.RichText;
    featuredImage: contentful.EntryFieldTypes.AssetLink;
    author: contentful.EntryFieldTypes.EntryLink<Author>;
    tags: contentful.EntryFieldTypes.Array<contentful.EntryFieldTypes.Symbol>;
    publishedDate: contentful.EntryFieldTypes.Date;
    seoTitle: contentful.EntryFieldTypes.Text;
    seoDescription: contentful.EntryFieldTypes.Text;
  };
}

export interface Author {
  contentTypeId: 'author';
  fields: {
    name: contentful.EntryFieldTypes.Text;
    bio: contentful.EntryFieldTypes.Text;
    avatar: contentful.EntryFieldTypes.AssetLink;
    twitter: contentful.EntryFieldTypes.Text;
  };
}

ดึงข้อมูล Blog Posts

// src/lib/posts.ts
import { contentfulClient } from './contentful';
import type { BlogPost } from './contentful';
import type { Entry } from 'contentful';

export async function getAllPosts(options?: {
  limit?: number;
  skip?: number;
  tag?: string;
}) {
  const response = await contentfulClient.getEntries<BlogPost>({
    content_type: 'blogPost',
    order: ['-fields.publishedDate'],
    limit: options?.limit ?? 10,
    skip: options?.skip ?? 0,
    ...(options?.tag && { 'fields.tags[in]': options.tag }),
    include: 2, // depth ของ linked entries
  });

  return {
    posts: response.items,
    total: response.total,
    limit: response.limit,
    skip: response.skip,
  };
}

export async function getPostBySlug(slug: string) {
  const response = await contentfulClient.getEntries<BlogPost>({
    content_type: 'blogPost',
    'fields.slug': slug,
    limit: 1,
    include: 3,
  });

  return response.items[0] ?? null;
}

export async function getAllSlugs() {
  const response = await contentfulClient.getEntries<BlogPost>({
    content_type: 'blogPost',
    select: ['fields.slug'],
    limit: 1000,
  });

  return response.items.map((item) => item.fields.slug);
}

Blog Index Page

---
// src/pages/blog/index.astro
import { getAllPosts } from '../../lib/posts';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';

const page = parseInt(Astro.url.searchParams.get('page') ?? '1');
const limit = 9;
const skip = (page - 1) * limit;

const { posts, total } = await getAllPosts({ limit, skip });
const totalPages = Math.ceil(total / limit);
---

<html lang="th">
  <body>
    <h1>บทความ</h1>
    
    <div class="posts-grid">
      {posts.map((post) => {
        const { title, slug, excerpt, featuredImage, publishedDate, author } = post.fields;
        const imageUrl = (featuredImage as any)?.fields?.file?.url;
        const authorName = (author as any)?.fields?.name;
        
        return (
          <article>
            {imageUrl && (
              <img src={`https:${imageUrl}?w=400&h=250&fit=fill`} alt={title} />
            )}
            <h2><a href={`/blog/${slug}`}>{title}</a></h2>
            <p>{excerpt}</p>
            <p>โดย {authorName} | {new Date(publishedDate).toLocaleDateString('th-TH')}</p>
          </article>
        );
      })}
    </div>
    
    <!-- Pagination -->
    <nav>
      {page > 1 && <a href={`/blog?page=${page - 1}`}>ก่อนหน้า</a>}
      <span>หน้า {page} / {totalPages}</span>
      {page < totalPages && <a href={`/blog?page=${page + 1}`}>ถัดไป</a>}
    </nav>
  </body>
</html>

Rich Text Renderer

// src/lib/richText.ts
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
import { BLOCKS, INLINES, MARKS } from '@contentful/rich-text-types';
import type { Document } from '@contentful/rich-text-types';

export function renderRichText(document: Document): string {
  return documentToHtmlString(document, {
    renderNode: {
      [BLOCKS.PARAGRAPH]: (node, next) =>
        `<p class="mb-4">${next(node.content)}</p>`,
      [BLOCKS.HEADING_1]: (node, next) =>
        `<h1 class="text-4xl font-bold mb-6">${next(node.content)}</h1>`,
      [BLOCKS.HEADING_2]: (node, next) =>
        `<h2 class="text-3xl font-bold mb-4">${next(node.content)}</h2>`,
      [BLOCKS.EMBEDDED_ASSET]: (node) => {
        const { file, title } = node.data.target.fields;
        return `<img src="https:${file.url}" alt="${title}" class="rounded-lg my-6" />`;
      },
      [INLINES.HYPERLINK]: (node, next) =>
        `<a href="${node.data.uri}" class="text-blue-600 hover:underline">${next(node.content)}</a>`,
    },
    renderMark: {
      [MARKS.CODE]: (text) =>
        `<code class="bg-gray-100 px-1 rounded">${text}</code>`,
    },
  });
}

Webhook สำหรับ ISR

// src/pages/api/revalidate.ts
import type { APIRoute } from 'astro';

export const POST: APIRoute = async ({ request }) => {
  const secret = request.headers.get('x-contentful-webhook-secret');

  if (secret !== import.meta.env.WEBHOOK_SECRET) {
    return new Response('Unauthorized', { status: 401 });
  }

  const body = await request.json();
  const slug = body.fields?.slug?.['en-US'];

  if (slug) {
    // Trigger revalidation
    console.log(`Revalidating: /blog/${slug}`);
  }

  return new Response(JSON.stringify({ revalidated: true }));
};

สรุป

Contentful กับ Astro เป็นคู่ที่เหมาะสำหรับ enterprise websites ที่ต้องการ content management ที่ครบครัน ด้วย rich content modeling, localization support, และ powerful API ทำให้ทีม content สามารถจัดการเนื้อหาได้อย่างมีประสิทธิภาพ