63 KiB
Kotobane CMS Website Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Next.js 14 website backed by Directus CMS at https://cms.achmad.dev where articles are published without code changes or redeployment.
Architecture: Next.js 14 App Router with ISR + on-demand revalidation. Pages are pre-built static HTML. When an article is published in Directus, a webhook fires to /api/revalidate, which calls revalidatePath() to surgically regenerate only the affected pages — no rebuild, content live within seconds.
Tech Stack: Next.js 14 (App Router), TypeScript, Tailwind CSS, @directus/sdk, Lucide React, next/font (Inter + Noto Sans JP), Vitest + Testing Library
Design reference: docs/design-reference/kotobane-reference.html (open in browser)
Brand system: docs/superpowers/specs/kotobane-brand-system.md
Implementation spec: docs/superpowers/specs/2026-05-28-kotobane-cms-website-design.md
File Map
Files created or modified by this plan:
| File | Purpose |
|---|---|
next.config.ts |
Image remote patterns for Directus assets |
tailwind.config.ts |
Design system tokens (colors, spacing, fonts, radii) |
app/globals.css |
Base styles, article body prose |
app/layout.tsx |
Root layout: fonts, Navbar, Footer, SearchOverlay |
app/page.tsx |
Homepage (ISR, on-demand revalidation) |
app/[category]/page.tsx |
Category listing page |
app/[category]/[slug]/page.tsx |
Article detail page |
app/api/revalidate/route.ts |
POST webhook handler from Directus |
app/sitemap.ts |
Dynamic XML sitemap |
app/robots.ts |
robots.txt |
components/layout/Navbar.tsx |
Server Component — fetches categories, renders NavbarClient |
components/layout/NavbarClient.tsx |
Client Component — active nav, search trigger |
components/layout/Footer.tsx |
Static footer |
components/home/HeroSection.tsx |
Featured article hero panel |
components/home/ArticleGrid.tsx |
Article grid with section heading |
components/article/ArticleCard.tsx |
Card used in grids and listings |
components/article/ArticleBody.tsx |
Renders Directus HTML rich text |
components/article/TagRow.tsx |
Tag chip row below article body |
components/article/LoadMoreButton.tsx |
Client Component — category pagination |
components/search/SearchOverlay.tsx |
Client Component — Cmd+K search modal |
lib/types.ts |
TypeScript interfaces for Directus collections |
lib/directus.ts |
Directus SDK singleton + typed fetch helpers |
vitest.config.ts |
Vitest configuration |
vitest.setup.ts |
Test setup |
__tests__/lib/directus.test.ts |
Directus helper unit tests |
__tests__/api/revalidate.test.ts |
Revalidate endpoint tests |
Dockerfile |
Multi-stage Docker image for the Next.js app |
.dockerignore |
Files excluded from Docker build context |
.gitea/workflows/deploy.yml |
Gitea Actions CD workflow |
.env.local |
Environment variables (never committed) |
.env.example |
Environment variable template |
Task 1: Bootstrap Next.js project
Files:
-
Create: all project files via
create-next-app -
Modify:
next.config.ts— add Directus image remote pattern -
Create:
.env.local,.env.example -
Step 1: Initialize git repository
cd /Users/achmad/Documents/Belajar/Web/kotobane
git init
git add docs/ .superpowers/
git commit -m "chore: add brainstorming docs and design reference"
- Step 2: Scaffold Next.js 14 into current directory
npx create-next-app@14 . --typescript --tailwind --eslint --app --no-src-dir --import-alias="@/*" --no-git
When prompted "Ok to proceed?" type y. Accept all defaults. The scaffold will create files alongside the existing docs/ and .superpowers/ directories.
Expected: Success! Created kotobane at ...
- Step 3: Install additional dependencies
npm install @directus/sdk lucide-react
npm install -D vitest @vitejs/plugin-react jsdom @testing-library/react @testing-library/jest-dom @testing-library/user-event
- Step 4: Add test script to package.json
In package.json, inside "scripts", add:
"test": "vitest",
"test:run": "vitest run"
- Step 5: Configure next.config.ts for Directus images
Replace the entire contents of next.config.ts:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.achmad.dev',
pathname: '/assets/**',
},
],
},
}
export default nextConfig
- Step 6: Create .env.local
Create .env.local in the project root (this file must never be committed):
DIRECTUS_URL=https://cms.achmad.dev
DIRECTUS_TOKEN=<read-only static token from Directus — generated in Task 17 Step 5>
REVALIDATE_SECRET=<generate with: openssl rand -base64 32>
NEXT_PUBLIC_DIRECTUS_URL=https://cms.achmad.dev
NEXT_PUBLIC_DIRECTUS_TOKEN=<same read-only token>
NEXT_PUBLIC_BASE_URL=https://kotobane.achmad.dev
- Step 7: Create .env.example
Create .env.example:
DIRECTUS_URL=https://cms.achmad.dev
DIRECTUS_TOKEN=
REVALIDATE_SECRET=
NEXT_PUBLIC_DIRECTUS_URL=https://cms.achmad.dev
NEXT_PUBLIC_DIRECTUS_TOKEN=
NEXT_PUBLIC_BASE_URL=https://kotobane.achmad.dev
- Step 8: Verify .env.local is in .gitignore
Check that .gitignore contains .env.local (create-next-app adds this). If not, add it manually.
- Step 9: Verify dev server starts
npm run dev
Open http://localhost:3000 — should show default Next.js page. Stop with Ctrl+C.
- Step 10: Commit
git add -A -- ':!.env.local'
git commit -m "chore: bootstrap Next.js 14 with Directus deps and config"
Task 2: Tailwind design tokens
Files:
-
Modify:
tailwind.config.ts— full design system tokens -
Modify:
app/globals.css— base styles + article body prose -
Step 1: Replace tailwind.config.ts
import type { Config } from 'tailwindcss'
import { fontFamily } from 'tailwindcss/defaultTheme'
const config: Config = {
content: [
'./app/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
],
theme: {
extend: {
colors: {
bg: {
DEFAULT: '#0D0D14',
elevated: '#171A21',
card: '#1D212B',
},
border: {
DEFAULT: '#2A3140',
},
accent: {
DEFAULT: '#00B4D8',
hover: '#00D4FF',
},
violet: '#7C3AED',
coral: '#D64545',
amber: '#FFB300',
green: '#00D166',
text: {
primary: '#F3F5F7',
secondary: '#B8C0CC',
muted: '#7D8795',
disabled: '#5C6470',
},
},
borderRadius: {
sm: '8px',
md: '12px',
lg: '16px',
xl: '20px',
'2xl': '28px',
},
fontFamily: {
sans: ['var(--font-inter)', ...fontFamily.sans],
jp: ['var(--font-noto-sans-jp)', ...fontFamily.sans],
},
},
},
plugins: [],
}
export default config
- Step 2: Replace app/globals.css
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
body {
@apply bg-bg text-text-primary antialiased;
}
}
@layer components {
/* Article body prose — scopes styles to Directus HTML rich text output */
.article-body {
@apply text-base leading-relaxed text-text-secondary;
}
.article-body p {
@apply mb-5;
}
.article-body h2 {
@apply text-2xl font-bold text-text-primary mt-9 mb-4;
}
.article-body h3 {
@apply text-xl font-semibold text-text-primary mt-7 mb-3;
}
.article-body strong {
@apply text-text-primary font-semibold;
}
.article-body a {
@apply text-accent underline hover:text-accent-hover;
}
.article-body ul {
@apply list-disc pl-6 mb-5 space-y-1;
}
.article-body ol {
@apply list-decimal pl-6 mb-5 space-y-1;
}
.article-body blockquote {
@apply border-l-4 border-accent bg-bg-card rounded-r-md pl-5 pr-4 py-3 my-6;
}
.article-body blockquote p {
@apply mb-0 italic;
}
.article-body img {
@apply rounded-md w-full my-6;
}
.article-body code:not(pre code) {
@apply bg-bg-card text-accent font-mono text-sm px-1.5 py-0.5 rounded;
}
.article-body pre {
@apply bg-bg-card rounded-md p-4 overflow-x-auto my-6 text-sm font-mono;
}
.article-body hr {
@apply border-border my-8;
}
}
- Step 3: Commit
git add tailwind.config.ts app/globals.css
git commit -m "feat: add design system tokens to Tailwind"
Task 3: Test infrastructure
Files:
-
Create:
vitest.config.ts -
Create:
vitest.setup.ts -
Step 1: Create vitest.config.ts
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import { resolve } from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./vitest.setup.ts'],
},
resolve: {
alias: {
'@': resolve(__dirname, '.'),
},
},
})
- Step 2: Create vitest.setup.ts
import '@testing-library/jest-dom'
- Step 3: Run vitest to confirm zero tests (clean baseline)
npm run test:run
Expected output: No test files found, exiting with code 1 — that's fine, no tests yet.
- Step 4: Commit
git add vitest.config.ts vitest.setup.ts package.json
git commit -m "chore: add Vitest + Testing Library test infrastructure"
Task 4: TypeScript types
Files:
-
Create:
lib/types.ts -
Step 1: Create lib/types.ts
export type ArticleStatus = 'published' | 'draft'
export interface Category {
id: string
name: string
slug: string
description: string | null
}
export interface Tag {
id: string
name: string
slug: string
}
export interface ArticleTag {
tags_id: Tag
}
export interface Article {
id: string
title: string
slug: string
status: ArticleStatus
content: string | null // HTML string from Directus WYSIWYG
excerpt: string | null
featured_image: string | null // UUID — use getAssetUrl() from lib/directus.ts
published_at: string | null
is_featured: boolean
seo_title: string | null
seo_description: string | null
category: Category
tags: ArticleTag[]
}
export interface SiteSettings {
id: string
site_name: string
hero_article: Article | null
nav_categories: Category[]
}
- Step 2: Commit
git add lib/types.ts
git commit -m "feat: TypeScript types for Directus collections"
Task 5: Directus SDK client
Files:
-
Create:
lib/directus.ts -
Create:
__tests__/lib/directus.test.ts -
Step 1: Write the failing tests
Create __tests__/lib/directus.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest'
const mockRequest = vi.fn()
const mockWith = vi.fn()
vi.mock('@directus/sdk', () => ({
createDirectus: vi.fn(() => ({ with: mockWith })),
rest: vi.fn(() => 'rest-plugin'),
staticToken: vi.fn(() => 'token-plugin'),
readItems: vi.fn((collection: string) => ({ _op: 'readItems', collection })),
readSingleton: vi.fn((collection: string) => ({ _op: 'readSingleton', collection })),
readItem: vi.fn((collection: string, id: string) => ({ _op: 'readItem', collection, id })),
}))
mockWith.mockReturnValue({ with: mockWith, request: mockRequest })
beforeEach(() => {
vi.resetModules()
mockRequest.mockReset()
process.env.DIRECTUS_URL = 'https://cms.achmad.dev'
process.env.DIRECTUS_TOKEN = 'test-token'
})
describe('getAllCategories', () => {
it('returns array of categories from Directus', async () => {
mockRequest.mockResolvedValue([
{ id: '1', name: 'Anime', slug: 'anime', description: null },
{ id: '2', name: 'VTubers', slug: 'vtubers', description: null },
])
const { getAllCategories } = await import('@/lib/directus')
const result = await getAllCategories()
expect(result).toHaveLength(2)
expect(result[0].slug).toBe('anime')
})
})
describe('getArticleBySlug', () => {
it('returns null when no article matches', async () => {
mockRequest.mockResolvedValue([])
const { getArticleBySlug } = await import('@/lib/directus')
const result = await getArticleBySlug('nonexistent')
expect(result).toBeNull()
})
it('returns the first matching article', async () => {
mockRequest.mockResolvedValue([
{
id: '1', title: 'Frieren S2', slug: 'frieren-s2', status: 'published',
content: '<p>Body</p>', excerpt: 'Short', featured_image: null,
published_at: '2026-05-28T00:00:00Z', is_featured: false,
seo_title: null, seo_description: null,
category: { id: '1', name: 'Anime', slug: 'anime', description: null },
tags: [],
},
])
const { getArticleBySlug } = await import('@/lib/directus')
const result = await getArticleBySlug('frieren-s2')
expect(result?.title).toBe('Frieren S2')
})
})
describe('getAssetUrl', () => {
it('constructs a Directus asset URL from a file UUID', async () => {
const { getAssetUrl } = await import('@/lib/directus')
const url = getAssetUrl('abc-123-uuid')
expect(url).toBe('https://cms.achmad.dev/assets/abc-123-uuid')
})
it('appends width and quality query params', async () => {
const { getAssetUrl } = await import('@/lib/directus')
const url = getAssetUrl('abc-123-uuid', { width: 800, quality: 80 })
expect(url).toContain('width=800')
expect(url).toContain('quality=80')
})
})
- Step 2: Run to confirm tests fail
npm run test:run __tests__/lib/directus.test.ts
Expected: FAIL — Cannot find module '@/lib/directus'
- Step 3: Create lib/directus.ts
import {
createDirectus,
rest,
staticToken,
readItems,
readSingleton,
readItem,
} from '@directus/sdk'
import type { Article, Category, SiteSettings } from './types'
const directus = createDirectus(process.env.DIRECTUS_URL!)
.with(staticToken(process.env.DIRECTUS_TOKEN!))
.with(rest())
export async function getAllCategories(): Promise<Category[]> {
return directus.request(
readItems('categories', {
fields: ['id', 'name', 'slug', 'description'],
sort: ['name'],
})
) as Promise<Category[]>
}
export async function getCategoryBySlug(slug: string): Promise<Category | null> {
const results = await directus.request(
readItems('categories', {
fields: ['id', 'name', 'slug', 'description'],
filter: { slug: { _eq: slug } },
limit: 1,
})
) as Category[]
return results[0] ?? null
}
export async function getArticles(options: {
limit?: number
offset?: number
categorySlug?: string
} = {}): Promise<Article[]> {
const { limit = 12, offset = 0, categorySlug } = options
const filter: Record<string, unknown> = { status: { _eq: 'published' } }
if (categorySlug) {
filter['category'] = { slug: { _eq: categorySlug } }
}
return directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'status', 'excerpt', 'featured_image',
'published_at', 'is_featured',
'category.id', 'category.name', 'category.slug',
],
filter,
sort: ['-published_at'],
limit,
offset,
})
) as Promise<Article[]>
}
export async function getArticleBySlug(slug: string): Promise<Article | null> {
const results = await directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'status', 'content', 'excerpt',
'featured_image', 'published_at', 'is_featured',
'seo_title', 'seo_description',
'category.id', 'category.name', 'category.slug',
'tags.tags_id.id', 'tags.tags_id.name', 'tags.tags_id.slug',
],
filter: { slug: { _eq: slug }, status: { _eq: 'published' } },
limit: 1,
})
) as Article[]
return results[0] ?? null
}
export async function getArticlePathById(id: string): Promise<{
slug: string
category: { slug: string }
} | null> {
try {
return await directus.request(
readItem('articles', id, {
fields: ['slug', 'category.slug'],
})
) as { slug: string; category: { slug: string } }
} catch {
return null
}
}
export async function getRelatedArticles(
categorySlug: string,
excludeSlug: string,
): Promise<Article[]> {
return directus.request(
readItems('articles', {
fields: [
'id', 'title', 'slug', 'excerpt', 'featured_image',
'published_at', 'category.id', 'category.name', 'category.slug',
],
filter: {
status: { _eq: 'published' },
category: { slug: { _eq: categorySlug } },
slug: { _neq: excludeSlug },
},
sort: ['-published_at'],
limit: 4,
})
) as Promise<Article[]>
}
export async function getSiteSettings(): Promise<SiteSettings> {
return directus.request(
readSingleton('site_settings', {
fields: [
'id', 'site_name',
'hero_article.id', 'hero_article.title', 'hero_article.slug',
'hero_article.excerpt', 'hero_article.featured_image',
'hero_article.category.slug', 'hero_article.category.name',
'nav_categories.id', 'nav_categories.name', 'nav_categories.slug',
],
})
) as Promise<SiteSettings>
}
export async function searchArticles(query: string): Promise<Article[]> {
return directus.request(
readItems('articles', {
fields: ['id', 'title', 'slug', 'category.slug', 'category.name'],
search: query,
filter: { status: { _eq: 'published' } },
limit: 8,
})
) as Promise<Article[]>
}
export function getAssetUrl(
fileId: string,
params?: { width?: number; height?: number; quality?: number },
): string {
const url = new URL(`/assets/${fileId}`, process.env.DIRECTUS_URL!)
if (params?.width) url.searchParams.set('width', String(params.width))
if (params?.height) url.searchParams.set('height', String(params.height))
if (params?.quality) url.searchParams.set('quality', String(params.quality))
return url.toString()
}
- Step 4: Run tests to confirm they pass
npm run test:run __tests__/lib/directus.test.ts
Expected: 4 tests passed
- Step 5: Commit
git add lib/directus.ts __tests__/lib/directus.test.ts
git commit -m "feat: Directus SDK client with typed helpers"
Task 6: Navbar
Files:
-
Create:
components/layout/NavbarClient.tsx -
Create:
components/layout/Navbar.tsx -
Step 1: Create components/layout/NavbarClient.tsx
The Navbar search icon dispatches a kotobane:open-search custom event that SearchOverlay listens for (see Task 15).
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { Search } from 'lucide-react'
import type { Category } from '@/lib/types'
interface Props {
categories: Category[]
}
export default function NavbarClient({ categories }: Props) {
const pathname = usePathname()
function openSearch() {
window.dispatchEvent(new CustomEvent('kotobane:open-search'))
}
return (
<nav className="bg-bg-elevated border-b border-border sticky top-0 z-40">
<div className="max-w-[1200px] mx-auto px-6 h-14 flex items-center gap-6">
<Link href="/" className="text-lg font-black text-accent tracking-[3px] shrink-0">
言羽
</Link>
<div className="w-px h-4 bg-border shrink-0" />
<div className="flex items-center gap-1 overflow-x-auto">
{categories.map((cat) => {
const isActive = pathname.startsWith(`/${cat.slug}`)
return (
<Link
key={cat.slug}
href={`/${cat.slug}`}
className={`text-xs font-medium px-3 py-1 rounded-sm whitespace-nowrap transition-colors ${
isActive
? 'text-accent'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{cat.name}
</Link>
)
})}
</div>
<button
onClick={openSearch}
className="ml-auto flex items-center gap-2 text-text-muted hover:text-text-secondary transition-colors shrink-0"
aria-label="Open search"
>
<Search size={16} />
<kbd className="hidden sm:inline-flex bg-bg-card border border-border rounded px-1.5 py-0.5 text-[10px] font-mono">
⌘K
</kbd>
</button>
</div>
</nav>
)
}
- Step 2: Create components/layout/Navbar.tsx
import { getAllCategories } from '@/lib/directus'
import NavbarClient from './NavbarClient'
export default async function Navbar() {
const categories = await getAllCategories()
return <NavbarClient categories={categories} />
}
- Step 3: Commit
git add components/layout/
git commit -m "feat: Navbar with server-fetched categories"
Task 7: Footer
Files:
-
Create:
components/layout/Footer.tsx -
Step 1: Create components/layout/Footer.tsx
export default function Footer() {
return (
<footer className="border-t border-border mt-16">
<div className="max-w-[1200px] mx-auto px-6 py-8 flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-lg font-black text-accent tracking-[3px]">言羽</span>
<p className="text-xs text-text-muted text-center">
Japanese pop-culture news · VTubers · Anime · Manga · Games · Music · Japan
</p>
<p className="text-xs text-text-disabled">
© {new Date().getFullYear()} Kotobane
</p>
</div>
</footer>
)
}
- Step 2: Commit
git add components/layout/Footer.tsx
git commit -m "feat: Footer component"
Task 8: Root layout
Files:
-
Modify:
app/layout.tsx -
Step 1: Create a SearchOverlay stub to unblock the layout
Create components/search/SearchOverlay.tsx as a temporary placeholder (replaced in Task 15):
'use client'
export default function SearchOverlay() {
return null
}
- Step 2: Replace app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import { Noto_Sans_JP } from 'next/font/google'
import './globals.css'
import Navbar from '@/components/layout/Navbar'
import Footer from '@/components/layout/Footer'
import SearchOverlay from '@/components/search/SearchOverlay'
const inter = Inter({
subsets: ['latin'],
variable: '--font-inter',
display: 'swap',
})
const notoSansJP = Noto_Sans_JP({
subsets: ['latin'],
variable: '--font-noto-sans-jp',
display: 'swap',
weight: ['400', '500', '600', '700'],
})
export const metadata: Metadata = {
title: {
template: '%s — Kotobane',
default: 'Kotobane — Japanese Pop-Culture News',
},
description: 'VTubers, Anime, Manga, Games, Music, and Japanese culture news.',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${inter.variable} ${notoSansJP.variable}`}>
<body>
<Navbar />
<main>{children}</main>
<Footer />
<SearchOverlay />
</body>
</html>
)
}
- Step 3: Verify the layout renders
npm run dev
Open http://localhost:3000 — Navbar and Footer should be visible. The nav will be empty until Directus collections are set up (Task 17). Stop with Ctrl+C.
- Step 4: Commit
git add app/layout.tsx components/search/SearchOverlay.tsx
git commit -m "feat: root layout with fonts, Navbar, Footer"
Task 9: ArticleCard component
Files:
-
Create:
components/article/ArticleCard.tsx -
Create:
__tests__/components/ArticleCard.test.tsx -
Step 1: Write failing test
Create __tests__/components/ArticleCard.test.tsx:
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import ArticleCard from '@/components/article/ArticleCard'
import type { Article } from '@/lib/types'
const mockArticle: Article = {
id: '1',
title: 'Frieren Season 2 Officially Announced',
slug: 'frieren-season-2-announced',
status: 'published',
content: null,
excerpt: 'The beloved series returns.',
featured_image: null,
published_at: '2026-05-28T00:00:00Z',
is_featured: false,
seo_title: null,
seo_description: null,
category: { id: '1', name: 'Anime', slug: 'anime', description: null },
tags: [],
}
describe('ArticleCard', () => {
it('renders the article title', () => {
render(<ArticleCard article={mockArticle} />)
expect(screen.getByText('Frieren Season 2 Officially Announced')).toBeInTheDocument()
})
it('renders the category name', () => {
render(<ArticleCard article={mockArticle} />)
expect(screen.getByText('Anime')).toBeInTheDocument()
})
it('links to the correct article URL', () => {
render(<ArticleCard article={mockArticle} />)
const link = screen.getByRole('link')
expect(link).toHaveAttribute('href', '/anime/frieren-season-2-announced')
})
it('renders the Read → CTA', () => {
render(<ArticleCard article={mockArticle} />)
expect(screen.getByText('Read →')).toBeInTheDocument()
})
})
- Step 2: Run to confirm it fails
npm run test:run __tests__/components/ArticleCard.test.tsx
Expected: FAIL — Cannot find module '@/components/article/ArticleCard'
- Step 3: Create components/article/ArticleCard.tsx
import Image from 'next/image'
import Link from 'next/link'
import { getAssetUrl } from '@/lib/directus'
import type { Article } from '@/lib/types'
interface Props {
article: Article
}
export default function ArticleCard({ article }: Props) {
const href = `/${article.category.slug}/${article.slug}`
return (
<Link
href={href}
className="group block bg-bg-card border border-border rounded-md overflow-hidden hover:border-[#3a4560] transition-colors"
>
<div className="relative aspect-video bg-bg-elevated overflow-hidden">
{article.featured_image ? (
<Image
src={getAssetUrl(article.featured_image, { width: 640, quality: 80 })}
alt={article.title}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
/>
) : (
<div className="w-full h-full bg-bg-elevated" />
)}
</div>
<div className="p-3">
<p className="text-[10px] font-semibold text-text-muted uppercase tracking-widest mb-1.5">
{article.category.name}
</p>
<h3 className="text-sm font-semibold text-text-primary leading-snug mb-3 line-clamp-2 group-hover:text-accent transition-colors">
{article.title}
</h3>
<span className="text-xs font-semibold text-accent">Read →</span>
</div>
</Link>
)
}
- Step 4: Run tests to confirm they pass
npm run test:run __tests__/components/ArticleCard.test.tsx
Expected: 4 tests passed
- Step 5: Commit
git add components/article/ArticleCard.tsx __tests__/components/ArticleCard.test.tsx
git commit -m "feat: ArticleCard component"
Task 10: HeroSection
Files:
-
Create:
components/home/HeroSection.tsx -
Step 1: Create components/home/HeroSection.tsx
import Image from 'next/image'
import Link from 'next/link'
import { getAssetUrl } from '@/lib/directus'
import type { Article } from '@/lib/types'
interface Props {
article: Article
}
export default function HeroSection({ article }: Props) {
const href = `/${article.category.slug}/${article.slug}`
return (
<section className="max-w-[1200px] mx-auto px-6 pt-10 pb-8">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-0 bg-bg-card border border-border rounded-lg overflow-hidden">
{/* Image */}
<div className="relative aspect-video lg:aspect-auto lg:min-h-[320px] bg-bg-elevated">
{article.featured_image ? (
<Image
src={getAssetUrl(article.featured_image, { width: 900, quality: 85 })}
alt={article.title}
fill
priority
className="object-cover"
sizes="(max-width: 1024px) 100vw, 50vw"
/>
) : (
<div className="absolute inset-0 bg-bg-elevated" />
)}
</div>
{/* Content */}
<div className="p-8 flex flex-col justify-center">
<div className="flex items-center gap-2 mb-4">
<span className="bg-violet text-white text-[9px] font-bold uppercase tracking-widest px-2 py-1 rounded">
Featured
</span>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-widest">
{article.category.name}
</span>
</div>
<h1 className="text-2xl lg:text-3xl font-bold text-text-primary leading-snug mb-4">
{article.title}
</h1>
{article.excerpt && (
<p className="text-sm text-text-secondary leading-relaxed mb-6">
{article.excerpt}
</p>
)}
<Link
href={href}
className="self-start inline-flex items-center gap-2 bg-accent text-black text-sm font-semibold px-5 py-2.5 rounded-sm hover:bg-accent-hover transition-colors"
>
Read article →
</Link>
</div>
</div>
</section>
)
}
- Step 2: Commit
git add components/home/HeroSection.tsx
git commit -m "feat: HeroSection component"
Task 11: ArticleGrid and Homepage
Files:
-
Create:
components/home/ArticleGrid.tsx -
Modify:
app/page.tsx -
Step 1: Create components/home/ArticleGrid.tsx
import ArticleCard from '@/components/article/ArticleCard'
import type { Article } from '@/lib/types'
interface Props {
articles: Article[]
title?: string
}
export default function ArticleGrid({ articles, title }: Props) {
if (articles.length === 0) return null
return (
<section className="max-w-[1200px] mx-auto px-6 py-6">
{title && (
<div className="flex items-center gap-4 mb-5">
<h2 className="text-xs font-bold text-text-primary uppercase tracking-widest shrink-0">
{title}
</h2>
<div className="flex-1 h-px bg-border" />
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
</section>
)
}
- Step 2: Replace app/page.tsx
import { getSiteSettings, getArticles } from '@/lib/directus'
import HeroSection from '@/components/home/HeroSection'
import ArticleGrid from '@/components/home/ArticleGrid'
export const revalidate = false
export default async function HomePage() {
const [settings, latestArticles] = await Promise.all([
getSiteSettings(),
getArticles({ limit: 12 }),
])
const heroArticle = settings.hero_article
const nonHeroArticles = heroArticle
? latestArticles.filter((a) => a.id !== heroArticle.id)
: latestArticles
return (
<>
{heroArticle && <HeroSection article={heroArticle} />}
<ArticleGrid articles={nonHeroArticles} title="Latest" />
</>
)
}
- Step 3: Commit
git add components/home/ArticleGrid.tsx app/page.tsx
git commit -m "feat: ArticleGrid and Homepage"
Task 12: ArticleBody and TagRow
Files:
-
Create:
components/article/ArticleBody.tsx -
Create:
components/article/TagRow.tsx -
Step 1: Create components/article/ArticleBody.tsx
Directus WYSIWYG outputs an HTML string. The .article-body styles in globals.css (Task 2) scope all typography.
interface Props {
html: string
}
export default function ArticleBody({ html }: Props) {
return (
<div
className="article-body"
dangerouslySetInnerHTML={{ __html: html }}
/>
)
}
- Step 2: Create components/article/TagRow.tsx
import type { ArticleTag } from '@/lib/types'
interface Props {
tags: ArticleTag[]
}
export default function TagRow({ tags }: Props) {
if (!tags || tags.length === 0) return null
return (
<div className="flex flex-wrap gap-2 pt-7 border-t border-border mt-8">
{tags.map(({ tags_id: tag }) => (
<span
key={tag.id}
className="bg-bg-card border border-border text-text-secondary text-xs px-3 py-1 rounded-sm hover:border-accent hover:text-accent transition-colors cursor-default"
>
{tag.name}
</span>
))}
</div>
)
}
- Step 3: Commit
git add components/article/ArticleBody.tsx components/article/TagRow.tsx
git commit -m "feat: ArticleBody renderer and TagRow"
Task 13: Article detail page
Files:
-
Create:
app/[category]/[slug]/page.tsx -
Step 1: Create app/[category]/[slug]/page.tsx
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import Image from 'next/image'
import Link from 'next/link'
import {
getArticleBySlug,
getRelatedArticles,
getAllCategories,
getArticles,
getAssetUrl,
} from '@/lib/directus'
import ArticleBody from '@/components/article/ArticleBody'
import TagRow from '@/components/article/TagRow'
import ArticleGrid from '@/components/home/ArticleGrid'
interface Props {
params: Promise<{ category: string; slug: string }>
}
export const revalidate = false
export async function generateStaticParams() {
const categories = await getAllCategories()
const articlesByCategory = await Promise.all(
categories.map((cat) => getArticles({ categorySlug: cat.slug, limit: 1000 }))
)
return articlesByCategory.flat().map((article) => ({
category: article.category.slug,
slug: article.slug,
}))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params
const article = await getArticleBySlug(slug)
if (!article) return {}
return {
title: article.seo_title ?? article.title,
description: article.seo_description ?? article.excerpt ?? undefined,
openGraph: {
title: article.seo_title ?? article.title,
description: article.seo_description ?? article.excerpt ?? undefined,
images: article.featured_image
? [getAssetUrl(article.featured_image, { width: 1200, quality: 80 })]
: [],
},
}
}
export default async function ArticlePage({ params }: Props) {
const { category: categorySlug, slug } = await params
const article = await getArticleBySlug(slug)
if (!article || article.category.slug !== categorySlug) {
notFound()
}
const related = await getRelatedArticles(categorySlug, slug)
const publishedDate = article.published_at
? new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(article.published_at))
: null
return (
<>
<article className="max-w-[780px] mx-auto px-6 pt-10 pb-8">
{/* Breadcrumb */}
<nav
className="flex items-center gap-2 text-xs text-text-disabled mb-7"
aria-label="Breadcrumb"
>
<Link href="/" className="text-text-muted hover:text-accent transition-colors">
Home
</Link>
<span>›</span>
<Link
href={`/${article.category.slug}`}
className="text-text-muted hover:text-accent transition-colors"
>
{article.category.name}
</Link>
<span>›</span>
<span className="text-text-muted truncate">{article.title}</span>
</nav>
{/* Header */}
<p className="text-[10px] font-bold uppercase tracking-[2px] text-text-muted mb-3">
{article.category.name}
</p>
<h1 className="text-3xl lg:text-4xl font-bold text-text-primary leading-tight mb-4">
{article.title}
</h1>
{article.excerpt && (
<p className="text-lg text-text-secondary leading-relaxed mb-6">
{article.excerpt}
</p>
)}
{/* Meta bar */}
<div className="flex items-center gap-3 py-3.5 border-t border-b border-border mb-8">
{article.is_featured && (
<span className="bg-violet text-white text-[9px] font-bold uppercase tracking-widest px-2 py-1 rounded">
Featured
</span>
)}
{publishedDate && (
<span className="text-xs text-text-muted">{publishedDate}</span>
)}
</div>
{/* Hero image */}
{article.featured_image && (
<div className="relative aspect-video rounded-md overflow-hidden mb-9">
<Image
src={getAssetUrl(article.featured_image, { width: 1200, quality: 85 })}
alt={article.title}
fill
priority
className="object-cover"
sizes="(max-width: 780px) 100vw, 780px"
/>
</div>
)}
{/* Body */}
{article.content && <ArticleBody html={article.content} />}
{/* Tags */}
<TagRow tags={article.tags} />
</article>
{/* Related articles */}
{related.length > 0 && (
<div className="max-w-[1200px] mx-auto">
<ArticleGrid
articles={related}
title={`More from ${article.category.name}`}
/>
</div>
)}
</>
)
}
- Step 2: Commit
git add app/[category]/[slug]/page.tsx
git commit -m "feat: article detail page with ISR and generateMetadata"
Task 14: Category listing page
Files:
-
Create:
components/article/LoadMoreButton.tsx -
Create:
app/[category]/page.tsx -
Step 1: Create components/article/LoadMoreButton.tsx
This client component fetches more articles directly from the Directus REST API on click. It uses NEXT_PUBLIC_ env vars (set in Task 1 .env.local) because this code runs in the browser.
'use client'
import { useState } from 'react'
import type { Article } from '@/lib/types'
import ArticleCard from './ArticleCard'
interface Props {
categorySlug: string
initialCount: number
}
export default function LoadMoreButton({ categorySlug, initialCount }: Props) {
const [articles, setArticles] = useState<Article[]>([])
const [offset, setOffset] = useState(initialCount)
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
async function loadMore() {
setLoading(true)
try {
const params = new URLSearchParams({
'filter[status][_eq]': 'published',
'filter[category][slug][_eq]': categorySlug,
sort: '-published_at',
limit: '12',
offset: String(offset),
fields:
'id,title,slug,excerpt,featured_image,published_at,is_featured,category.id,category.name,category.slug',
access_token: process.env.NEXT_PUBLIC_DIRECTUS_TOKEN ?? '',
})
const res = await fetch(
`${process.env.NEXT_PUBLIC_DIRECTUS_URL}/items/articles?${params}`
)
const data = await res.json()
const newArticles: Article[] = data.data ?? []
setArticles((prev) => [...prev, ...newArticles])
setOffset((prev) => prev + newArticles.length)
if (newArticles.length < 12) setHasMore(false)
} finally {
setLoading(false)
}
}
return (
<>
{articles.length > 0 && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 mt-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
)}
{hasMore && (
<div className="flex justify-center mt-8 pb-8">
<button
onClick={loadMore}
disabled={loading}
className="bg-bg-card border border-border text-text-secondary text-sm font-medium px-6 py-2.5 rounded-sm hover:border-accent hover:text-accent transition-colors disabled:opacity-50"
>
{loading ? 'Loading…' : 'Load more'}
</button>
</div>
)}
</>
)
}
- Step 2: Create app/[category]/page.tsx
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
import { getCategoryBySlug, getArticles, getAllCategories } from '@/lib/directus'
import ArticleCard from '@/components/article/ArticleCard'
import LoadMoreButton from '@/components/article/LoadMoreButton'
interface Props {
params: Promise<{ category: string }>
}
export const revalidate = false
export async function generateStaticParams() {
const categories = await getAllCategories()
return categories.map((cat) => ({ category: cat.slug }))
}
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { category: categorySlug } = await params
const category = await getCategoryBySlug(categorySlug)
if (!category) return {}
return {
title: category.name,
description:
category.description ?? `Latest ${category.name} news and articles on Kotobane.`,
}
}
export default async function CategoryPage({ params }: Props) {
const { category: categorySlug } = await params
const [category, articles] = await Promise.all([
getCategoryBySlug(categorySlug),
getArticles({ categorySlug, limit: 24 }),
])
if (!category) notFound()
return (
<div className="max-w-[1200px] mx-auto px-6 pt-10">
<div className="mb-8">
<h1 className="text-3xl font-bold text-text-primary mb-2">{category.name}</h1>
{category.description && (
<p className="text-text-secondary text-sm">{category.description}</p>
)}
</div>
{articles.length === 0 ? (
<p className="text-text-muted text-sm">No articles yet.</p>
) : (
<>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{articles.map((article) => (
<ArticleCard key={article.id} article={article} />
))}
</div>
<LoadMoreButton categorySlug={categorySlug} initialCount={articles.length} />
</>
)}
</div>
)
}
- Step 3: Commit
git add app/[category]/page.tsx components/article/LoadMoreButton.tsx
git commit -m "feat: category listing page with load-more pagination"
Task 15: SearchOverlay
Files:
-
Modify:
components/search/SearchOverlay.tsx— replace stub with full implementation -
Step 1: Replace components/search/SearchOverlay.tsx
The overlay listens for kotobane:open-search (fired by Navbar search button) and for the Cmd+K keyboard shortcut.
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
import { useRouter } from 'next/navigation'
import { Search, X } from 'lucide-react'
import type { Article } from '@/lib/types'
export default function SearchOverlay() {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<Article[]>([])
const [activeIndex, setActiveIndex] = useState(0)
const [loading, setLoading] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const router = useRouter()
const close = useCallback(() => {
setOpen(false)
setQuery('')
setResults([])
setActiveIndex(0)
}, [])
useEffect(() => {
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'k' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
setOpen(true)
}
if (e.key === 'Escape') close()
}
function onOpenSearch() {
setOpen(true)
}
window.addEventListener('keydown', onKeyDown)
window.addEventListener('kotobane:open-search', onOpenSearch)
return () => {
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('kotobane:open-search', onOpenSearch)
}
}, [close])
useEffect(() => {
if (open) setTimeout(() => inputRef.current?.focus(), 50)
}, [open])
useEffect(() => {
if (!query.trim()) {
setResults([])
return
}
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(async () => {
setLoading(true)
try {
const params = new URLSearchParams({
search: query.trim(),
'filter[status][_eq]': 'published',
limit: '8',
fields: 'id,title,slug,category.slug,category.name',
access_token: process.env.NEXT_PUBLIC_DIRECTUS_TOKEN ?? '',
})
const res = await fetch(
`${process.env.NEXT_PUBLIC_DIRECTUS_URL}/items/articles?${params}`
)
const data = await res.json()
setResults(data.data ?? [])
setActiveIndex(0)
} finally {
setLoading(false)
}
}, 300)
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current)
}
}, [query])
function navigate(article: Article) {
router.push(`/${article.category.slug}/${article.slug}`)
close()
}
function onKeyDown(e: React.KeyboardEvent) {
if (e.key === 'ArrowDown') {
e.preventDefault()
setActiveIndex((i) => Math.min(i + 1, results.length - 1))
} else if (e.key === 'ArrowUp') {
e.preventDefault()
setActiveIndex((i) => Math.max(i - 1, 0))
} else if (e.key === 'Enter' && results[activeIndex]) {
navigate(results[activeIndex])
}
}
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center pt-[15vh]"
onClick={close}
>
<div className="absolute inset-0 bg-black/60" />
<div
className="relative w-full max-w-[560px] mx-4 bg-bg-elevated border border-border rounded-lg shadow-xl overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* Input */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<Search size={16} className="text-text-muted shrink-0" />
<input
ref={inputRef}
type="text"
placeholder="Search articles…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
className="flex-1 bg-transparent text-sm text-text-primary placeholder:text-text-disabled outline-none"
/>
{query && (
<button
onClick={() => setQuery('')}
className="text-text-muted hover:text-text-secondary"
aria-label="Clear search"
>
<X size={14} />
</button>
)}
</div>
{/* Results */}
{results.length > 0 && (
<ul>
{results.map((article, i) => (
<li key={article.id}>
<button
onClick={() => navigate(article)}
className={`w-full text-left px-4 py-3 flex flex-col gap-0.5 transition-colors ${
i === activeIndex ? 'bg-bg-card' : 'hover:bg-bg-card'
}`}
>
<span className="text-[10px] font-semibold text-text-muted uppercase tracking-widest">
{article.category.name}
</span>
<span className="text-sm text-text-primary">{article.title}</span>
</button>
</li>
))}
</ul>
)}
{loading && (
<p className="px-4 py-3 text-sm text-text-muted">Searching…</p>
)}
{!loading && query.trim() && results.length === 0 && (
<p className="px-4 py-3 text-sm text-text-muted">No results for "{query}"</p>
)}
{/* Keyboard hints */}
<div className="flex items-center gap-4 px-4 py-2 border-t border-border">
<span className="text-[10px] text-text-disabled">↑↓ navigate</span>
<span className="text-[10px] text-text-disabled">↵ open</span>
<span className="text-[10px] text-text-disabled">esc close</span>
</div>
</div>
</div>
)
}
- Step 2: Verify search overlay manually
npm run dev
Open http://localhost:3000. Press Cmd+K (Mac) or Ctrl+K (Windows/Linux). The overlay should appear with a search input. Press Escape — it should close. Stop with Ctrl+C.
- Step 3: Commit
git add components/search/SearchOverlay.tsx
git commit -m "feat: Cmd+K search overlay with debounced Directus query"
Task 16: Revalidate endpoint
Files:
-
Modify:
app/api/revalidate/route.ts -
Create:
__tests__/api/revalidate.test.ts -
Step 1: Write the failing tests
Create __tests__/api/revalidate.test.ts:
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { NextRequest } from 'next/server'
const mockRevalidatePath = vi.fn()
const mockRequest = vi.fn()
const mockWith = vi.fn()
vi.mock('next/cache', () => ({ revalidatePath: mockRevalidatePath }))
vi.mock('@directus/sdk', () => ({
createDirectus: vi.fn(() => ({ with: mockWith })),
rest: vi.fn(),
staticToken: vi.fn(),
readItem: vi.fn(),
}))
mockWith.mockReturnValue({ with: mockWith, request: mockRequest })
beforeEach(() => {
vi.resetModules()
mockRevalidatePath.mockReset()
mockRequest.mockReset()
mockWith.mockReturnValue({ with: mockWith, request: mockRequest })
process.env.REVALIDATE_SECRET = 'test-secret'
process.env.DIRECTUS_URL = 'https://cms.achmad.dev'
process.env.DIRECTUS_TOKEN = 'test-token'
})
describe('POST /api/revalidate', () => {
it('returns 401 when secret is wrong', async () => {
const { POST } = await import('@/app/api/revalidate/route')
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ secret: 'wrong-secret', article_id: '123' }),
})
const res = await POST(req)
expect(res.status).toBe(401)
expect(mockRevalidatePath).not.toHaveBeenCalled()
})
it('returns 400 when article_id is missing and type is not homepage', async () => {
const { POST } = await import('@/app/api/revalidate/route')
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ secret: 'test-secret' }),
})
const res = await POST(req)
expect(res.status).toBe(400)
})
it('revalidates /, /category, and /category/slug on valid article request', async () => {
mockRequest.mockResolvedValue({
slug: 'frieren-season-2',
category: { slug: 'anime' },
})
const { POST } = await import('@/app/api/revalidate/route')
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ secret: 'test-secret', article_id: 'uuid-123' }),
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
expect(mockRevalidatePath).toHaveBeenCalledWith('/anime')
expect(mockRevalidatePath).toHaveBeenCalledWith('/anime/frieren-season-2')
})
it('returns 404 when article is not found in Directus', async () => {
mockRequest.mockResolvedValue(null)
const { POST } = await import('@/app/api/revalidate/route')
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ secret: 'test-secret', article_id: 'nonexistent' }),
})
const res = await POST(req)
expect(res.status).toBe(404)
expect(mockRevalidatePath).not.toHaveBeenCalled()
})
it('revalidates only / when type is homepage', async () => {
const { POST } = await import('@/app/api/revalidate/route')
const req = new NextRequest('http://localhost/api/revalidate', {
method: 'POST',
body: JSON.stringify({ secret: 'test-secret', type: 'homepage' }),
})
const res = await POST(req)
expect(res.status).toBe(200)
expect(mockRevalidatePath).toHaveBeenCalledTimes(1)
expect(mockRevalidatePath).toHaveBeenCalledWith('/')
})
})
- Step 2: Run to confirm tests fail
npm run test:run __tests__/api/revalidate.test.ts
Expected: FAIL — route not implemented yet.
- Step 3: Create app/api/revalidate/route.ts
import { revalidatePath } from 'next/cache'
import { NextRequest, NextResponse } from 'next/server'
import { createDirectus, rest, staticToken, readItem } from '@directus/sdk'
export async function POST(req: NextRequest) {
const body = await req.json()
if (body.secret !== process.env.REVALIDATE_SECRET) {
return NextResponse.json({ error: 'Invalid secret' }, { status: 401 })
}
if (body.type === 'homepage') {
revalidatePath('/')
return NextResponse.json({ revalidated: true, path: '/' })
}
const { article_id } = body
if (!article_id) {
return NextResponse.json({ error: 'Missing article_id' }, { status: 400 })
}
const directus = createDirectus(process.env.DIRECTUS_URL!)
.with(staticToken(process.env.DIRECTUS_TOKEN!))
.with(rest())
const article = (await directus.request(
readItem('articles', article_id, {
fields: ['slug', 'category.slug'],
})
)) as { slug: string; category: { slug: string } } | null
if (!article) {
return NextResponse.json({ error: 'Article not found' }, { status: 404 })
}
const categorySlug = article.category.slug
const { slug } = article
revalidatePath('/')
revalidatePath(`/${categorySlug}`)
revalidatePath(`/${categorySlug}/${slug}`)
return NextResponse.json({
revalidated: true,
paths: ['/', `/${categorySlug}`, `/${categorySlug}/${slug}`],
})
}
- Step 4: Run tests to confirm they pass
npm run test:run __tests__/api/revalidate.test.ts
Expected: 5 tests passed
- Step 5: Run all tests
npm run test:run
Expected: All tests pass (ArticleCard + directus helpers + revalidate = 13 tests total).
- Step 6: Commit
git add app/api/revalidate/route.ts __tests__/api/revalidate.test.ts
git commit -m "feat: revalidate webhook endpoint with tests"
Task 17: Directus collections setup (manual — performed in browser)
Go to https://cms.achmad.dev. No code changes in this task.
-
Step 1: Create
categoriescollection- Settings → Data Model → Create Collection
- Name:
categories, Primary Key: auto-increment integer - Fields to add:
name(Input, required),slug(Input, unique + required),description(Textarea) - Add 8 initial records in Content → categories:
name slug Anime anime VTubers vtubers Manga manga Games games Music music Japan japan Culture culture Industry industry -
Step 2: Create
tagscollection- Create Collection:
tags - Fields:
name(Input),slug(Input, unique)
- Create Collection:
-
Step 3: Create
articlescollection- Create Collection:
articles— enable the Status field during the wizard - Add fields one by one:
Field Interface Notes titleInput Required slugInput Unique + required. In field options, enable "Generate Slug from title" contentWYSIWYG Rich text editor excerptTextarea Short summary for cards featured_imageImage M2O relation to directus_files published_atDateTime Set manually on first publish is_featuredToggle Default: false seo_titleInput Optional SEO override seo_descriptionTextarea Optional SEO override categoryMany to One Related Collection: categories, Required tagsMany to Many Related Collection: tags, Junction Collection: articles_tags - Create Collection:
-
Step 4: Create
site_settingssingleton- Create Collection:
site_settings, check Singleton option - Fields:
site_name(Input, default "Kotobane"),hero_article(M2O → articles),nav_categories(M2M → categories) - Content → site_settings → set
site_nameto "Kotobane", leave others blank for now
- Create Collection:
-
Step 5: Create a read-only access token
- Settings → Access Control → Create a new Role named "Frontend"
- Give the Frontend role read-only access to: articles (published only), categories, tags, site_settings, directus_files
- Settings → Users → Create a user "Frontend Token", assign the Frontend role
- In the user record, scroll to Token and generate a static token
- Copy this token into
.env.localfor bothDIRECTUS_TOKENandNEXT_PUBLIC_DIRECTUS_TOKEN
-
Step 6: Add a test article
- Content → articles → + New
- Fill: title
Test Article, categoryAnime, content (a few paragraphs), statusPublished - Save → verify it appears at:
curl "https://cms.achmad.dev/items/articles?filter[status][_eq]=published&access_token=YOUR_TOKEN"Expected: JSON response with
dataarray containing the test article. -
Step 7: Set up Directus Flow for article revalidation
- Settings → Flows → Create Flow
- Name: "Revalidate on Article Publish"
- Trigger: Event Hook
- Scope:
items.create,items.update - Collections:
articles - When: After Event
- Scope:
- Add Action: Webhook / Request
- Method: POST
- URL:
https://yourdomain.com/api/revalidate(update after deploy) - Request Body:
{ "secret": "YOUR_REVALIDATE_SECRET", "article_id": "{{$trigger.key}}" } - Headers:
Content-Type: application/json
-
Step 8: Set up Directus Flow for homepage revalidation
- Create a second Flow: "Revalidate Homepage on Settings Update"
- Trigger: Event Hook on
site_settings, scope:items.update - Action: Webhook POST to
/api/revalidate - Body:
{ "secret": "YOUR_REVALIDATE_SECRET", "type": "homepage" }
Task 18: SEO — sitemap and robots
Files:
-
Create:
app/sitemap.ts -
Create:
app/robots.ts -
Step 1: Create app/sitemap.ts
import type { MetadataRoute } from 'next'
import { getAllCategories, getArticles } from '@/lib/directus'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://kotobane.achmad.dev'
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
const [categories, articles] = await Promise.all([
getAllCategories(),
getArticles({ limit: 1000 }),
])
const categoryUrls: MetadataRoute.Sitemap = categories.map((cat) => ({
url: `${BASE_URL}/${cat.slug}`,
changeFrequency: 'daily',
priority: 0.8,
}))
const articleUrls: MetadataRoute.Sitemap = articles.map((article) => ({
url: `${BASE_URL}/${article.category.slug}/${article.slug}`,
lastModified: article.published_at ? new Date(article.published_at) : new Date(),
changeFrequency: 'weekly',
priority: 0.6,
}))
return [
{ url: BASE_URL, changeFrequency: 'hourly', priority: 1.0 },
...categoryUrls,
...articleUrls,
]
}
- Step 2: Create app/robots.ts
import type { MetadataRoute } from 'next'
const BASE_URL = process.env.NEXT_PUBLIC_BASE_URL ?? 'https://kotobane.achmad.dev'
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/' },
sitemap: `${BASE_URL}/sitemap.xml`,
}
}
- Step 3: Commit
git add app/sitemap.ts app/robots.ts
git commit -m "feat: dynamic sitemap and robots.txt"
Task 19: Final build verification
- Step 1: Run all tests
npm run test:run
Expected: All 13 tests pass.
- Step 2: Production build
Ensure .env.local has valid tokens and Directus collections are set up (Task 17).
npm run build
Expected: Build completes successfully with output like:
Route (app) Size
┌ ○ / ...
├ ● /[category] ...
└ ● /[category]/[slug] ...
If you see Error: ... DIRECTUS_TOKEN is not configured — fill in .env.local with the token from Task 17 Step 5.
- Step 3: Smoke-test the production build locally
npm start
Open http://localhost:3000 and verify:
| Check | Expected |
|---|---|
| Homepage | Loads; shows articles from Directus |
| Category nav link | Navigates to /anime, shows articles |
| Article card click | Navigates to /anime/test-article |
| Article page | Title, body, tags all render |
| Cmd+K | Search overlay opens |
| Search for "test" | Results appear within 300ms |
| Escape | Overlay closes |
/sitemap.xml |
XML with homepage + category + article URLs |
/robots.txt |
User-agent: *, Allow: / |
Stop with Ctrl+C.
- Step 4: Test the revalidate endpoint manually
# Start the server in a separate terminal
npm start
# Test in another terminal
curl -s -X POST http://localhost:3000/api/revalidate \
-H "Content-Type: application/json" \
-d "{\"secret\":\"$(grep REVALIDATE_SECRET .env.local | cut -d= -f2)\",\"type\":\"homepage\"}" | jq .
Expected: { "revalidated": true, "path": "/" }
- Step 5: Commit
git add -A -- ':!.env.local'
git commit -m "chore: verified production build and all checks pass"
Task 20: Docker + Gitea CD setup
Files:
-
Modify:
next.config.ts— addoutput: 'standalone' -
Create:
Dockerfile -
Create:
.dockerignore -
Create:
.gitea/workflows/deploy.yml -
Step 1: Enable standalone output in next.config.ts
Add output: 'standalone' to the Next.js config so the Docker image only bundles what's needed:
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
output: 'standalone',
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cms.achmad.dev',
pathname: '/assets/**',
},
],
},
}
export default nextConfig
- Step 2: Create Dockerfile
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
- Step 3: Create .dockerignore
node_modules
.next
.env.local
.git
docs
- Step 4: Create .gitea/workflows/deploy.yml
Runs on your self-hosted Gitea Actions runner. Rebuilds the image and restarts the container on every push to main. Adjust the service name to match whatever you call it in your Docker Compose setup.
name: Deploy
on:
push:
branches:
- main
jobs:
deploy:
runs-on: self-hosted
steps:
- uses: actions/checkout@v3
- name: Build and redeploy
run: |
docker compose build kotobane
docker compose up -d --no-deps kotobane
- Step 5: Commit
git add next.config.ts Dockerfile .dockerignore .gitea/workflows/deploy.yml
git commit -m "ci: Docker build and Gitea Actions deploy workflow"
- Step 6: Update Directus Flow webhook URL
In Directus admin → Settings → Flows → "Revalidate on Article Publish" → update the webhook URL to https://kotobane.achmad.dev/api/revalidate. Do the same for the homepage flow.