A full-stack Blog CMS built with Next.js (App Router), Supabase, TailwindCSS, and shadcn/ui. Features Supabase Auth with role-based access control, a WYSIWYG editor, draft/publish workflow, newsletter subscriptions, an AI writing assistant, a headless REST API, and MCP-powered development workflows.
- Authentication via Supabase Auth
- Role-Based Access Control — Admin and Author roles enforced through Supabase RLS
- WYSIWYG editor powered by TipTap with rich text, images, and formatting
- Draft and publish workflow
- Tags and categories
- Comments — authenticated, thread-style, with admin management
- SEO-friendly public blog pages with meta title and description support
- Developer API — generate API keys in the dashboard to create posts from external tools (n8n, Postman, scripts)
- AI Writing Assistant — chat with uploaded books (PDF) using Claude, Gemini, or OpenAI; generate full blog post drafts from conversation context
- LLM provider key management — store encrypted API keys (AES-256-GCM) for Claude, Gemini, and OpenAI per user
- Headless AI post generation via
POST /api/ai-assistant/generate - Newsletter subscriptions — readers subscribe from a widget on every post; email sent automatically on publish via Resend after a configurable delay; one-click unsubscribe via token
- REST API for posts — list, create, read, update, delete via authenticated endpoints
- In-memory rate limiting on API routes
- Favicon support
- Fast Vercel deployment
- Frontend: Next.js (App Router)
- Backend: Supabase (Postgres + Auth + Storage)
- Styling: TailwindCSS + shadcn/ui
- Editor: TipTap
- AI Providers: Anthropic (Claude), Google (Gemini), OpenAI
- Deployment: Vercel
- AI Dev Layer: Claude Code + MCP Servers
This project is optimized for AI-assisted development using MCP servers:
github-mcp— repo management, PRs, commitssupabase-mcp— database schema, queries, RLSvercel-mcp— deployments and env managementfilesystem-mcp— file editing and refactoringbrowser-mcp— UI testing and debuggingpostgres-mcp(optional) — query optimization
app/
(public)/ → public blog pages
(dashboard)/ → admin & author dashboard
(ai)/ → AI assistant (full-screen layout)
api/ → backend routes
components/
ui/ → reusable UI (shadcn/ui)
editor/ → TipTap WYSIWYG editor
blog/ → blog components
features/
posts/
users/
auth/
comments/
lib/
supabase/
permissions/
utils/
database/
schema.sql
migrations/
policies/
agents/
frontend.agent.md
backend.agent.md
database.agent.md
git clone https://github.com/frank-mendez/nextjs-blog-cms.git
cd nextjs-blog-cmsUse Node 24.21.0 (.nvmrc) and pnpm 10.29.3 (packageManager in
package.json). pnpm 10 is supported by Vercel and was verified against the
existing dependency graph; dependency upgrades remain separate in issue #62.
nvm install
nvm use
corepack enable
pnpm --version # must print 10.29.3
pnpm install --frozen-lockfileIf Corepack is unavailable, install the pinned package manager with
npm install --global pnpm@10.29.3, then run the same pnpm commands.
See dependency upgrade notes for migrations, image host configuration, verification, and tracked compatibility blockers.
Commit package.json and pnpm-lock.yaml together when changing dependencies.
Use pnpm add <package> / pnpm add -D <package>; do not generate an npm lockfile.
CI installs pnpm from the manifest before enabling the pnpm cache and uses the
Node patch in .nvmrc. Required peers are checked strictly without blanket bypasses.
Dependency build scripts are reviewed in pnpm-workspace.yaml: only
unrs-resolver is allowed to bootstrap/check its native resolver; msw's optional
browser-worker copy script is ignored because no worker directory is configured.
Review new scripts before adding an approval; do not enable all dependency scripts.
The root prepare script installs Husky hooks normally.
For Vercel, set the project Node runtime to 24.x and enable
ENABLE_EXPERIMENTAL_COREPACK=1 in the project environment (Preview and
Production). vercel.json uses
corepack pnpm for frozen installation and build, so Corepack reads the exact
manifest pin instead of relying on Vercel's default pnpm version. The committed
pnpm lockfile also provides package-manager detection. Vercel manages the Node
24 patch; local development and CI pin .nvmrc. See Vercel package managers.
Historical plans under docs/superpowers/ retain their original npm commands.
Create a .env.local file:
NEXT_PUBLIC_SUPABASE_URL=
NEXT_PUBLIC_SUPABASE_ANON_KEY=
SUPABASE_SERVICE_ROLE_KEY=
LLM_KEY_ENCRYPTION_SECRET= # 32-character secret for AES-256-GCM key encryption
# Newsletter
RESEND_API_KEY=
RESEND_FROM_EMAIL= # verified sender address, e.g. noreply@yourdomain.com
NEWSLETTER_DELAY_MINUTES=60 # delay between publish and send (default: 60)
WEBHOOK_SECRET= # shared secret used to authenticate the /api/newsletter/send cron call- Run
database/schema.sqlin the Supabase SQL editor - Apply RLS policies from
database/policies/ - Optionally seed with
database/seed.sql
pnpm run dev| Role | Access |
|---|---|
| Admin | Full control (users, posts, roles, comments, developer settings) |
| Author | Create and manage own posts, delete own comments |
Enforced using Supabase Row Level Security (RLS).
The AI assistant allows authors to upload a PDF book, chat with it using their preferred LLM, and generate a full blog post draft from the conversation.
Supported providers: Claude (Anthropic), Gemini (Google), OpenAI
How it works:
- Navigate to Dashboard → AI Assistant
- Add your LLM API key under Dashboard → Developer → LLM Providers
- Start a new chat — upload a PDF and select a model
- Chat with the book, then click Generate Post to create a draft
PDF text is extracted on upload and stored as plain text. The LLM receives the extracted text as context. API keys are encrypted with AES-256-GCM and never stored in plaintext.
Admins can generate API keys to allow external tools to create posts without a browser session.
- Log in as Admin
- Go to Dashboard → Developer
- Click Generate New Key, name it, and copy the key — shown only once
Keys are prefixed with fmblog_ followed by 64 hex characters. Only a SHA-256 hash is stored in the database.
Create a new post from any HTTP client.
Headers:
Authorization: Bearer fmblog_your_key_here
Content-Type: application/json
Body:
| Field | Type | Required | Description |
|---|---|---|---|
title |
string | Yes | Post title |
content |
string | Yes | HTML content (TipTap-compatible) |
slug |
string | No | URL slug — auto-generated from title if omitted |
status |
draft | published |
No | Defaults to draft |
excerpt |
string | No | Plain-text summary |
meta_title |
string | No | SEO title — defaults to title |
meta_description |
string | No | SEO description — defaults to excerpt |
tags |
string[] | No | Tag names — created automatically if they don't exist |
category |
string | No | Category name — matched by name or slug |
image_url |
string | No | Featured image URL |
Example:
curl -X POST https://your-domain.com/api/posts/create \
-H "Authorization: Bearer fmblog_your_key_here" \
-H "Content-Type: application/json" \
-d '{
"title": "Hello from n8n",
"content": "<p>This post was created via the API.</p>",
"status": "draft",
"tags": ["automation", "n8n"],
"category": "Technology"
}'Response (201):
{
"success": true,
"data": {
"id": "uuid",
"title": "Hello from n8n",
"slug": "hello-from-n8n",
"status": "draft"
}
}Generate a blog post headlessly using the AI assistant.
Headers:
Authorization: Bearer fmblog_your_key_here
Content-Type: application/json
List posts with pagination and filters.
Retrieve a single post by ID.
Update a post by ID.
Delete a post by ID.
- Raw API keys are never stored — only SHA-256 hashes
- The key is shown exactly once after generation
- Keys can be revoked or deleted at any time from Developer Settings
author_idis always set to the user who owns the API key- API routes are rate-limited in-memory
Readers subscribe via a widget at the bottom of every blog post. When a post is published, a send is queued in the newsletter_sends table and dispatched after a configurable delay.
- Reader submits their email on any blog post — stored in
newsletter_subscriptions - When a post is published, a row is inserted into
newsletter_sendswithscheduled_at = now() + NEWSLETTER_DELAY_MINUTES - A Vercel Cron Job (or any HTTP scheduler) calls
POST /api/newsletter/sendevery minute - The endpoint claims pending sends past their
scheduled_at, emails all active subscribers via Resend, and marks the send assent
Every email contains a unique unsubscribe link: GET /api/newsletter/unsubscribe?token=<token>. Clicking it sets unsubscribed_at and redirects to /newsletter/unsubscribed.
Go to Dashboard → Admin → Newsletter (admin only) to see:
- Active subscribers, sends dispatched, and unsubscribed counts
- Pending and in-progress scheduled sends
- Recent subscriber list with status badges
- CSV export of all subscribers
A vercel.json is included at the repo root that configures the cron to fire every minute. The endpoint requires a x-webhook-secret header matching WEBHOOK_SECRET — add this to your Vercel project environment variables. Vercel Cron sends the header automatically when the secret is configured in the project settings.
The login page links to /forgot-password. Requests use Supabase Auth and always
show the same confirmation for registered and unregistered email addresses.
Passwords must contain at least eight characters, matching registration and
account settings. The signup server action enforces this policy even when browser
validation is bypassed. Login still accepts older six-character passwords.
Additional Supabase password rules are enforced by Auth and reported during recovery.
Before deploying this feature:
- Apply the
add_password_recovery_grantsmigration. Its table has RLS enabled and noanon/authenticatedprivileges; only server-side service-role code can create and consume grants. KeepSUPABASE_SERVICE_ROLE_KEYserver-only. - Set
NEXT_PUBLIC_SITE_URLto the trusted application origin, such ashttp://localhost:3000locally orhttps://blog.frankmendez.sitein production. Do not derive email destinations from incoming request headers. - In Supabase Authentication → URL Configuration, set the Site URL and allow
these exact redirects (use your production origin in place of the example):
http://localhost:3000/auth/callback?next=/reset-passwordhttp://127.0.0.1:3000/auth/callback?next=/reset-passwordhttps://blog.frankmendez.site/auth/callback?next=/reset-passwordKeep the existing signup/auth/callbackredirects allowed as well.
- In Authentication → Email Templates → Reset Password, copy
supabase/templates/recovery.html. Its link must be{{ .RedirectTo }}&token_hash={{ .TokenHash }}&type=recovery. This verifies the recovery OTP on the server and works when opened in another browser. The defaultConfirmationURLtemplate is not compatible with this recovery flow; old/default recovery links show an actionable retry message. Local Supabase loads the template fromsupabase/config.tomlautomatically; restart the local stack after changing email templates. - Configure production SMTP and appropriate email/OTP rate limits and expiry in
Supabase. Local email is captured by Mailpit (
supabase statusshows its URL). The request action hides address-specific email throttling/account errors; global throttling and delivery outages show neutral retry errors.
A verified email creates a 15-minute HttpOnly recovery capability bound to the
Supabase user and session. A normal login session cannot reset a password. The
reset page checks the capability, and the update action atomically deletes its
database row before calling auth.updateUser. Expired, invalid, consumed, and
replayed capabilities cannot authorize updates. Provider update failures also
consume the capability and require a new email. Authenticator verification
failures happen before consumption and can be retried. Successful updates request global sign-out, clear browser credentials even
if Auth sign-out is unavailable, and offer a link to sign in with the new
password. Supabase access tokens already issued may remain valid until their
normal expiry.
Deploy migration 20261006152137_limit_recovery_mfa_attempts.sql before enabling
this flow.
Accounts with a verified TOTP authenticator are prompted for a six-digit code before the recovery grant is consumed. Invalid or expired codes can be retried within the grant's 15-minute lifetime, up to five verification attempts. Each attempt is reserved atomically in the database before calling Auth, including concurrent requests. The fifth failed code invalidates the grant; a new reset email is then required. Interrupted verification calls also use an attempt. Supabase verifies the factor and upgrades the recovery session before the password change. Other verification methods require administrator assistance. Account-settings password changes remain outside this flow.
Expired grant rows are unusable and are automatically deleted before a new
recovery grant is issued. This uses the expiry index and removes abandoned grants
across all users. During periods without recovery traffic, expired rows can also
be removed with delete from public.password_recovery_grants where expires_at < now();.
Run pnpm exec vitest run __tests__/auth __tests__/lib/proxy.test.ts for recovery
unit/component tests. Run
pnpm exec playwright test e2e/browser/password-recovery.spec.ts --project=browser
against the dedicated disposable test environment described above. Set
E2E_MAILPIT_URL to its Mailpit origin (for example http://127.0.0.1:54324). The
browser test requests a real email, follows its actual link in a fresh browser,
checks validation, changes the password, verifies new-password sign-in and
old-password rejection, and verifies that the same link cannot be reused.
Covers lib utilities, API routes, services, and UI components with 80%+ thresholds across lines, branches, functions, and statements.
pnpm run typecheck # route types and full TypeScript check
pnpm test # watch mode
pnpm run test:run # single run
pnpm run test:coverage # coverage reportThe suite verifies 19 posts REST API cases and five browser flows: public navigation/newsletter/registration validation, editor save and publish, profile updates, PDF upload/chat, and MFA enrollment/login. Provider generation is stubbed; auth, PDF parsing, and database writes use the real local services.
Use a dedicated disposable Supabase test project, never production. Apply all supabase/migrations or start the local stack with pnpm dlx supabase@2.119.0 start. Put its NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY, and SUPABASE_SERVICE_ROLE_KEY in the ignored .env.e2e file. The Playwright server inherits those values. Set RESEND_API_KEY and SLACK_WEBHOOK_URL to empty values in that file to keep local verification isolated from external integrations.
pnpm exec playwright install chromium
pnpm run test:e2e # API and browser suites
pnpm run test:e2e --project=api # HTTP tests only
pnpm run test:e2e --project=browser # browser tests only
pnpm run test:e2e:report # open the HTML reportPlaywright starts its own server by default. Set E2E_BASE_URL for a different port; E2E_REUSE_SERVER=1 explicitly reuses an existing local server configured for the same test database. Global setup seeds a disposable author, API key, and posts; teardown removes that user's rows and auth account. The newsletter smoke test removes its own subscription.
- Import repo to Vercel
- Add environment variables
- Assign a domain (e.g.
blog.yourdomain.com)
This project is designed to work seamlessly with Claude Code:
- Modular, feature-based architecture for safe refactoring
- Dedicated
agents/instruction files - MCP servers for full-stack automation
Public Blog — SEO-friendly article listing
AI Assistant — Chat with books and generate blog posts
Developer Settings — API Key Management and LLM Providers
- Comments system
- Developer API with API key management
- AI Writing Assistant (Claude, Gemini, OpenAI)
- PDF text extraction and LLM context
- REST API for posts
- Newsletter subscriptions with auto-send on publish
- Analytics dashboard
- Scheduled posts
- Multi-author collaboration
- Headless CMS API
Contributions are welcome. To contribute:
- Fork the repository
- Create a feature branch (
git checkout -b feature/your-feature) - Commit your changes with clear messages
- Open a Pull Request — describe what changed and why
For significant changes, open an issue first to discuss the approach.
This project follows the Contributor Covenant Code of Conduct. By participating, you agree to uphold a respectful and inclusive environment. Report unacceptable behavior to the project maintainer.
Frank Mendez


