11 KiB
Kotobane — CMS-Driven Website Plan
Context
Kotobane is a Japanese pop-culture news site (VTubers, Anime, Manga, Games, Music, Japan, Culture, Industry) with a fully-defined design system. The goal is to build a website where content can be added and updated from a Directus CMS without touching code or redeploying. The user runs everything on a VPS and already has a Directus instance (fresh install, no collections yet). There is a single author (no authors collection needed).
Chosen architecture: Next.js 14 App Router with ISR + on-demand revalidation. Pages are served as pre-built static HTML; when the editor publishes in Directus, a Directus Flow fires a webhook to /api/revalidate which surgically regenerates only the affected pages — no rebuild, no redeploy, content live in seconds.
Design reference: docs/design-reference/kotobane-reference.html — single file with four tabs: Homepage mockup, Article page mockup, Color system, Directus workflow. Open in a browser for full visual reference.
Directus instance: https://cms.achmad.dev
Stack
- Frontend: Next.js 14 (App Router), TypeScript, Tailwind CSS
- CMS: Directus (existing VPS instance)
- Fonts: Inter (English) + Noto Sans JP (Japanese) via
next/font - Icons: Lucide React
- Rich text rendering:
@directus/sdk+ custom renderer for Directus rich text blocks - Image optimization: Next.js
<Image>pointing at Directus/assets/endpoint
Final Color Palette
Brand Accent
| Token | Hex | Usage |
|---|---|---|
| accent | #00B4D8 |
Primary buttons, links, active nav, "Read →", focus rings |
| accent-hover | #00D4FF |
Hover state |
Status / Badge Colors
| Token | Hex | Usage |
|---|---|---|
| violet | #7C3AED |
Featured / hero badges, secondary actions |
| coral | #D64545 |
Trending, breaking news, error states |
| amber | #FFB300 |
New indicator, alerts, warnings |
| green | #00D166 |
Published status, success states |
Backgrounds
| Token | Hex | Usage |
|---|---|---|
| bg | #0D0D14 |
Main page background |
| bg-elevated | #171A21 |
Navbar, sidebars |
| bg-card | #1D212B |
Cards, inputs |
| border | #2A3140 |
All borders and dividers |
Text
| Token | Hex | Usage |
|---|---|---|
| text-primary | #F3F5F7 |
Headlines, body |
| text-secondary | #B8C0CC |
Excerpts, captions |
| text-muted | #7D8795 |
Timestamps, category labels, metadata |
| text-disabled | #5C6470 |
Disabled / placeholder |
Category names in nav and cards use
text-muted— no per-category color coding. Colors are reserved for status badges only.
Directus Data Model
Create these collections in Directus admin:
articles
| Field | Type | Notes |
|---|---|---|
| title | string | required |
| slug | string | unique, required — used in URL |
| status | string | published / draft — Directus built-in |
| content | rich text (WYSIWYG) | full article body |
| excerpt | text | short blurb shown on cards |
| featured_image | file (M2O → directus_files) | hero image |
| published_at | datetime | set on first publish |
| is_featured | boolean | controls hero slot on homepage |
| seo_title | string | optional override for <title> |
| seo_description | string | optional override for meta description |
| category | M2O → categories | required |
| tags | M2M → tags (junction: articles_tags) | optional |
categories
| Field | Type | Notes |
|---|---|---|
| name | string | Anime, VTubers, Manga, Games, Music, Japan, Culture, Industry |
| slug | string | unique — used in URL |
| description | text | shown on category listing page |
tags
| Field | Type | Notes |
|---|---|---|
| name | string | e.g. "hololive", "Spring 2025" |
| slug | string | unique — for URL filtering |
site_settings (singleton)
| Field | Type | Notes |
|---|---|---|
| site_name | string | default: "Kotobane" |
| hero_article | M2O → articles | which article appears in the homepage hero |
| nav_categories | JSON / M2M → categories | ordered list for navbar |
Directus Flow (webhook trigger)
Create one Flow in Directus:
- Trigger: "Event Hook" on
articles.items.updateandarticles.items.createwherestatus = published - Action: Webhook POST to
https://yourdomain.com/api/revalidate - Payload:
{ "secret": "REVALIDATE_SECRET", "article_id": "{{$trigger.key}}" }
The revalidate endpoint receives the article ID, then queries Directus itself to get the full slug and category.slug — this is more reliable than relying on the Flow payload containing all fields (Directus only sends changed fields, so category may be absent if only status changed).
Also add a second Flow trigger on site_settings update to revalidate the homepage.
Next.js Project Structure
kotobane/
├── app/
│ ├── layout.tsx # Root layout: fonts, navbar, footer
│ ├── page.tsx # Homepage (ISR)
│ ├── [category]/
│ │ ├── page.tsx # Category listing (ISR)
│ │ └── [slug]/
│ │ └── page.tsx # Article detail (ISR)
│ └── api/
│ └── revalidate/
│ └── route.ts # POST endpoint for Directus webhook
├── components/
│ ├── layout/
│ │ ├── Navbar.tsx # Logo, nav categories, search icon
│ │ └── Footer.tsx
│ ├── home/
│ │ ├── HeroSection.tsx # Featured article hero
│ │ └── ArticleGrid.tsx # Latest articles grid
│ ├── article/
│ │ ├── ArticleCard.tsx # Card used in grids and listings
│ │ ├── ArticleBody.tsx # Rich text renderer
│ │ └── TagRow.tsx
│ └── search/
│ └── SearchOverlay.tsx # Cmd+K command palette search
├── lib/
│ ├── directus.ts # Directus SDK client + typed fetch helpers
│ └── types.ts # TypeScript types mirroring Directus collections
├── tailwind.config.ts # Design system tokens (colors, spacing, fonts)
└── .env.local # DIRECTUS_URL, DIRECTUS_TOKEN, REVALIDATE_SECRET
Pages
/ — Homepage (app/page.tsx)
- Fetch:
site_settings(hero article) + latest 12 published articles - Sections: Hero → Latest grid → articles grouped by 2–3 featured categories
- ISR:
revalidate = false(on-demand only, via webhook)
/[category] — Category listing (app/[category]/page.tsx)
- Fetch: category by slug + articles filtered by that category, sorted by
published_atdesc generateStaticParams: pre-build all category slugs at deploy time- Pagination: load more button (client-side, hits Directus with
offset)
/[category]/[slug] — Article (app/[category]/[slug]/page.tsx)
- Fetch: single article by slug with
fields=*,tags.*,category.* - Sections: Hero image → title → metadata → rich text body → tags → related articles (same category, limit 4)
generateMetadata: usesseo_title/seo_descriptionif set, falls back to article title/excerptgenerateStaticParams: pre-build all published articles at deploy time;dynamicParams = true(Next.js default) ensures new articles published after deploy are generated on first request via ISR — no rebuild needed
/api/revalidate — Webhook handler (app/api/revalidate/route.ts)
- Accepts POST with
{ secret, slug, category_slug } - Validates secret matches
REVALIDATE_SECRETenv var - Calls
revalidatePath('/'),revalidatePath('/[category]', 'page'),revalidatePath('/[category]/[slug]', 'page')
Search — SearchOverlay (global, client component)
- Triggered by Cmd+K or clicking the search icon in Navbar
- Debounced input (300ms) → live GET to Directus
/items/articles?search=query&limit=8&fields=title,slug,category.slug - Results rendered as keyboard-navigable list (↑↓ Enter)
- No ISR needed — always queries Directus live
Design System Integration (tailwind.config.ts)
Map the design system tokens directly into Tailwind:
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',
},
Standard Tailwind spacing (multiples of 4/8) maps cleanly to the 8px spacing system.
Directus SDK Client (lib/directus.ts)
Use @directus/sdk with a typed schema. Key helpers:
// Singleton client
const directus = createDirectus(DIRECTUS_URL).with(rest())
// Typed fetch helpers
getArticles(options) // list with filters
getArticleBySlug(slug) // single article
getCategoryBySlug(slug) // single category
getAllCategories() // for navbar + generateStaticParams
getSiteSettings() // homepage hero + nav order
searchArticles(query) // search overlay
All helpers use readItems / readSingleton from the SDK. Token auth via staticToken(DIRECTUS_TOKEN) — create a read-only token in Directus for the frontend.
Environment Variables
DIRECTUS_URL=https://cms.achmad.dev
DIRECTUS_TOKEN=<read-only static token from Directus>
REVALIDATE_SECRET=<random string, shared with Directus Flow>
Deployment (VPS)
- Run Next.js as a Node server:
next build && next start -p 3000 - Use PM2 or systemd to keep it running
- Nginx reverse proxy in front (handles SSL, proxies to port 3000)
- Directus already running on same VPS (separate port/domain)
Implementation Order
- Bootstrap —
create-next-app, install deps (@directus/sdk,lucide-react), configure Tailwind with design tokens, set up fonts - Directus collections — create all 4 collections + junction table in Directus admin, add a few test articles
- SDK client —
lib/directus.tswith typed helpers +lib/types.ts - Layout —
Navbar+Footer, rootlayout.tsx - Homepage —
HeroSection+ArticleGrid+page.tsx - Article page —
ArticleCard,ArticleBody(rich text renderer), full article page - Category page — listing with pagination
- Search overlay —
SearchOverlaycomponent wired to Cmd+K - Revalidate endpoint —
/api/revalidate+ test with curl - Directus Flow — set up webhook trigger in Directus
- SEO —
generateMetadataon article + category pages,sitemap.ts,robots.ts - Deploy — build, PM2/systemd, Nginx config
Verification
- Add a test article in Directus → confirm it appears on homepage within seconds (no restart)
- Update an article → confirm revalidate endpoint fires and page updates
- Test search overlay with Cmd+K
- Check mobile layout (single-column per design system)
- Confirm Directus going offline does not break cached pages for visitors
- Lighthouse score: aim for 90+ performance (static HTML + lazy images)