A self-hosted, production-ready blogging platform with a full admin panel, rich text editing, newsletter management, comment moderation, and comprehensive SEO — built on Next.js 16 and MongoDB.
- Overview
- Screenshots
- Architecture
- Tech Stack
- File Structure
- Features
- Getting Started
- Environment Variables
- Admin Panel
- Public Pages
- Deployment
- Cron Jobs
- License
- Author
PureBlog is a fully self-hosted blogging platform that gives you complete ownership over your content, readers, and data. It is designed around a single admin user model — no multi-tenant complexity, no third-party CMS dependency, no external content API. Everything from post creation to newsletter delivery runs within the same Next.js application.
The design philosophy behind PureBlog is clarity and restraint. The reader-facing interface is intentionally minimal: no sidebars cluttered with widgets, no aggressive popups, no visual noise. Typography is the primary medium. Every page is stripped back to what the content actually needs — a clean reading surface, generous whitespace, and a neutral palette that keeps the reader focused on the writing. The name reflects this directly: pure, uncluttered, honest.
The platform is built with the Next.js App Router, server components, and ISR caching, making it fast by default. The admin panel covers the full content lifecycle: write with a feature-complete rich text editor, organize with categories, tags, and series, moderate comments, manage subscribers, and configure the entire site appearance from a single settings page. Reader-facing features include full-text search, bookmarks, reactions, comment threads, series navigation, and an RSS feed.
Place screenshot files in
./public/screenshots/to display them here.
Homepage |
Blog Listing |
Blog Post |
Mobile View |
Dashboard |
Posts |
Rich Text Editor |
Authors |
Categories |
Tags |
Series |
Comments |
Messages |
Subscribers |
Settings |
|
PageSpeed Insights |
PureBlog uses the Next.js App Router with two distinct route groups. The (main) group handles all public-facing pages as server components with ISR. The admin/ group is session-gated and handles all content management operations via server actions and API routes. Static assets and uploaded media are served from Cloudinary.
Data access follows a MongoDB singleton connection pattern (lib/db.ts) with a connection pool of 10 to handle serverless cold starts efficiently. Reads on high-traffic routes are wrapped in unstable_cache with named tags (categories, settings, posts). Every admin mutation calls revalidateTag for the relevant tag, ensuring cached pages are rebuilt on the next request without a full redeploy.
Authentication is handled by NextAuth v5 with a credentials provider and JWT session strategy. Login attempts are rate-limited in-process at 5 requests per 15-minute window per IP. The same in-process rate limiter (lib/rate-limit.ts) protects the comment submission, contact form, and newsletter signup endpoints.
graph TD
Browser --> NextJS["Next.js 16 (App Router)"]
NextJS --> MainGroup["(main) — Public Pages (ISR)"]
NextJS --> AdminGroup["admin/ — Protected Panel"]
NextJS --> APIRoutes["api/ — REST endpoints"]
MainGroup --> CacheLayer["unstable_cache / revalidateTag"]
AdminGroup --> ServerActions["Server Actions"]
CacheLayer --> MongoDB[("MongoDB / Mongoose")]
ServerActions --> MongoDB
APIRoutes --> MongoDB
ServerActions --> Cloudinary["Cloudinary CDN"]
ServerActions --> Nodemailer["Nodemailer / Gmail SMTP"]
NextJS --> NextAuth["NextAuth v5 / JWT"]
NextAuth --> RateLimit["In-process Rate Limiter"]
| Category | Technology |
|---|---|
| Framework | Next.js 16.1.6 (App Router) |
| Language | TypeScript 5 |
| UI | React 19, Tailwind CSS 4, shadcn/ui, Base UI 1.3 |
| Animation | Framer Motion 12, Motion |
| Rich Text | Tiptap 3.21 with 20+ extensions |
| Database | MongoDB with Mongoose 9 |
| Auth | NextAuth v5 (credentials provider, JWT sessions) |
| Validation | Zod 3, React Hook Form 7 |
| Image Hosting | Cloudinary 2 |
| Nodemailer 8 + Gmail SMTP, React Email | |
| Icons | Lucide React |
| Date Handling | date-fns 4 |
| Deployment | Vercel (with cron jobs) |
| Package Manager | pnpm |
StarterKit, Bold, Italic, Underline, Strike, Link, Image (resizable), Placeholder, CodeBlockLowlight (syntax highlighting via Lowlight), Highlight, TextAlign, TextStyle, Color, FontFamily, FontSize, LineHeight, Subscript, Superscript, Table + TableRow + TableCell + TableHeader + TableCaption, TaskList + TaskItem, Typography, YouTube, CharacterCount — plus custom extensions: Audio, CodeSandbox embed, Columns (multi-column layout), Details/Summary (collapsible), FileAttachment, Indent, Math (KaTeX inline and block), Twitter/X card embed, Vimeo embed.
pureblog/
├── app/
│ ├── (main)/ # Public-facing route group
│ │ ├── page.tsx # Homepage
│ │ ├── blog/ # Blog listing and post detail pages
│ │ ├── author/ # Author list and author detail pages
│ │ ├── categories/ # Category list and category detail pages
│ │ ├── tags/ # Tag list and tag detail pages
│ │ ├── series/ # Series list and series detail pages
│ │ ├── search/ # Full-text search page
│ │ ├── bookmarks/ # Client-side saved posts page
│ │ ├── contact/ # Contact form page
│ │ ├── about/ # Static about page
│ │ └── preview/ # Token-gated draft preview
│ ├── admin/ # Protected admin panel (session-gated)
│ │ ├── page.tsx # Dashboard
│ │ ├── posts/ # Post list, create, and edit
│ │ ├── authors/ # Author management
│ │ ├── categories/ # Category management
│ │ ├── tags/ # Tag overview
│ │ ├── series/ # Series management
│ │ ├── comments/ # Comment moderation
│ │ ├── messages/ # Contact form inbox
│ │ ├── subscribers/ # Newsletter subscriber management
│ │ └── settings/ # Site-wide settings
│ ├── api/
│ │ ├── auth/ # NextAuth handlers
│ │ ├── admin/ # Admin CRUD API routes
│ │ ├── posts/ # View counter and reaction endpoints
│ │ ├── search/ # Full-text search endpoint
│ │ ├── contact/ # Contact form submission endpoint
│ │ ├── newsletter/ # Subscribe and unsubscribe endpoints
│ │ ├── upload/ # Cloudinary upload proxy
│ │ ├── og/ # Dynamic OG image generation
│ │ └── cron/publish/ # Scheduled post publishing (Vercel cron)
│ ├── feed.xml/ # RSS feed route
│ ├── sitemap.ts # Dynamic XML sitemap
│ ├── robots.ts # Robots.txt
│ └── manifest.ts # PWA manifest
├── components/
│ ├── ui/ # shadcn/ui primitives and custom UI components
│ ├── editor/ # Tiptap editor, toolbar, extensions, and node views
│ │ ├── tiptap-editor.tsx # Main editor component
│ │ ├── editor-toolbar.tsx # Formatting toolbar
│ │ ├── bubble-menu.tsx # Inline formatting context menu
│ │ ├── slash-command.tsx # Slash command menu for block insertion
│ │ └── extensions/ # Custom Tiptap extension implementations
│ ├── navbar.tsx # Site navigation
│ ├── footer.tsx # Site footer
│ ├── post-card.tsx # Post summary card
│ ├── featured-carousel.tsx # Featured posts carousel
│ ├── blog-posts.tsx # Paginated post listing
│ ├── comment-section.tsx # Comment submission and display
│ ├── reaction-bar.tsx # Heart reaction with confetti and sound
│ ├── table-of-contents.tsx # Auto-generated post TOC
│ ├── series-navigation.tsx # Previous/next navigation within a series
│ ├── share-buttons.tsx # Copy link and native share
│ ├── bookmark-button.tsx # localStorage bookmark toggle
│ ├── newsletter-section.tsx # Email subscription form
│ └── ... # Additional page-level components
├── lib/
│ ├── db.ts # MongoDB singleton connection (pool size 10)
│ ├── cache.ts # unstable_cache wrappers per data type
│ ├── mailer.ts # Nodemailer / Gmail email sending functions
│ ├── rate-limit.ts # In-process IP-based rate limiter
│ ├── metadata.ts # generateMetadata helpers for all routes
│ ├── reading-time.ts # Reading time calculation
│ ├── toc.ts # Table of contents parser from HTML headings
│ └── utils.ts # Class merging, slug generation, misc helpers
├── models/ # Mongoose schemas (Post, Author, Category, ...)
├── types/
│ └── index.ts # TypeScript interfaces for all data models
├── hooks/
│ ├── use-bookmarks.ts # localStorage bookmark management hook
│ └── use-sound.ts # Sound engine hook
├── emails/ # React Email transactional templates
│ ├── new-comment.tsx # Admin notification for new comments
│ ├── comment-approved.tsx # Commenter notification on approval
│ └── newsletter-post.tsx # Post announcement email template
├── public/ # Static assets and screenshots
├── styles/ # Global CSS
├── auth.ts # NextAuth configuration and credentials logic
├── env.ts # t3-env environment variable validation schema
├── next.config.ts # Security headers, image config, bundle analyzer
└── vercel.json # Vercel cron job schedule
- Full CRUD for posts, authors, categories, and series with automatic slug generation
- Draft and published status with an optional scheduled publishing date and time
- Bulk operations on the posts table: bulk delete and bulk publish
- CSV export for posts and subscribers
- Secure draft preview via a token-gated URL (
/preview/[slug]?token=...) — share unpublished content without exposing the admin panel - Post autosave to prevent content loss during editing
The editor is built on Tiptap 3 and supports the following:
- Text formatting: bold, italic, underline, strikethrough, text color, highlight, font size, font family, line height, subscript, superscript
- Block types: paragraph, headings (H1–H6), blockquote, code block with syntax highlighting, ordered and unordered lists, task lists
- Advanced blocks: tables with captions, multi-column layouts, collapsible details/summary sections, file attachments, audio embeds
- Media: images with drag-to-resize handles and Cloudinary upload, YouTube, Vimeo, Twitter/X card embeds, CodeSandbox embeds
- Math: inline and block math expressions rendered with KaTeX
- Editor tools: slash command menu for inserting any block type, bubble menu for inline formatting, block handle for drag-and-drop reordering, find and replace panel, HTML source view, character count, zen mode, typewriter mode
generateMetadata()on every page with title, description, canonical URL, Open Graph tags, and Twitter card tags- Dynamic OG image generation via
next/ogat/api/og— renders post title, publication date, and site name as a 1200×630 image - JSON-LD structured data:
Articleschema on post pages,WebSitewithSearchActionon the homepage,BreadcrumbListon taxonomy pages - Auto-generated sitemap at
/sitemap.xmlwith per-route priority and change frequency settings, updated bypublishedAttimestamps - Robots rules at
/robots.txt— disallows/admin,/api, and/login - MongoDB compound text index on
titleandcontentfor full-text search with relevance-score ranking
- ISR via
unstable_cachewith tag-based invalidation — category, settings, and post caches are each invalidated independently on their respective admin mutations viarevalidateTag - Optimized package imports for
lucide-react,motion/react, and@tiptap/reactviaexperimental.optimizePackageImportsinnext.config.ts next/imagewith a 30-day minimum cache TTL and a permissive remote pattern for Cloudinary and any HTTPS image source- Bundle analyzer available via
pnpm analyze(ANALYZE=trueenvironment variable)
The following HTTP response headers are applied to all routes via next.config.ts:
| Header | Value |
|---|---|
X-Frame-Options |
DENY |
X-Content-Type-Options |
nosniff |
Strict-Transport-Security |
max-age=63072000; includeSubDomains; preload |
Content-Security-Policy |
Restrictive policy, inline scripts blocked |
Permissions-Policy |
Camera, microphone, geolocation disabled |
Referrer-Policy |
strict-origin-when-cross-origin |
Additional security measures:
- Rate limiting on login (5 requests per 15 minutes), comment submission (5 per 10 minutes), contact form (3 per hour), and newsletter signup (3 per hour) — all keyed by IP using
x-real-ipwithx-forwarded-forfallback - Admin password stored as a bcrypt hash (
bcryptjs, cost factor 12) - Environment validation at startup via
@t3-oss/env-nextjswith Zod schemas — the application will not boot if any required variable is missing or fails type validation
- Subscriber management with
activeandunsubscribedstatus - Unique unsubscribe token per subscriber, embedded as a one-click link in every newsletter email
- Newsletter campaign dispatch from the admin panel — sends to all active subscribers simultaneously
- Scheduled and manual post publishing can trigger an automatic newsletter send to all active subscribers
- Three transactional email templates built with React Email:
- New comment notification sent to the admin
- Comment approval notification sent to the commenter
- Post announcement email sent to newsletter subscribers
- Heart reaction on posts: increments up to 3 times per user, triggers a canvas-confetti animation and sound effect on the third click
- View counter incremented server-side on each page visit
- Auto-generated table of contents from post headings with scroll-aware active state
- Series navigation bar when a post belongs to a series — links to previous and next posts in order
- Related posts by category (up to 3) displayed at the end of each post
- Comment section with nested reply support and pending/approved/rejected moderation states
- Client-side bookmarks stored in
localStorage, displayed on a dedicated/bookmarkspage - Share buttons: copy link to clipboard, native Web Share API (mobile), platform-specific share
- Previous and next post navigation at the bottom of every post
- Grid and list view toggle on the blog listing page
- Node.js 20 or later
- pnpm 9 or later — install with
npm install -g pnpm - A running MongoDB instance (local or MongoDB Atlas)
- A Cloudinary account — required for image uploads in the editor and settings
- A Gmail account with an App Password — required for email notifications and newsletter delivery
-
Clone the repository:
git clone https://github.com/semihkececioglu/pureblog.git cd pureblog -
Install dependencies:
pnpm install
-
Copy the example environment file:
cp .env.example .env.local
-
Fill in the required environment variables (see the table below).
-
Start the development server:
pnpm dev
-
Open
http://localhost:3000for the public blog andhttp://localhost:3000/adminfor the admin panel. Log in with the credentials you set inAUTH_ADMIN_EMAILandAUTH_ADMIN_PASSWORD_PLAIN.
| Variable | Description |
|---|---|
MONGODB_URI |
MongoDB connection string. Example: mongodb+srv://user:pass@cluster.mongodb.net/pureblog |
AUTH_SECRET |
Random secret for signing JWT sessions. Must be at least 32 characters. Generate with openssl rand -base64 32 |
AUTH_ADMIN_EMAIL |
Email address used to log in to the admin panel |
AUTH_ADMIN_PASSWORD |
Bcrypt hash of the admin password. Use this in production. |
AUTH_ADMIN_PASSWORD_PLAIN |
Admin password as plain text. Local development only. Overrides AUTH_ADMIN_PASSWORD when set |
NEXT_PUBLIC_APP_URL |
Full public URL of the deployed site including protocol. Example: https://yourblog.com |
| Variable | Description |
|---|---|
GMAIL_USER |
Gmail address used as the sender for all transactional and newsletter email |
GMAIL_APP_PASSWORD |
Gmail App Password (not your account password). Generate at myaccount.google.com/apppasswords |
CLOUDINARY_CLOUD_NAME |
Cloudinary cloud name |
CLOUDINARY_API_KEY |
Cloudinary API key |
CLOUDINARY_API_SECRET |
Cloudinary API secret |
CRON_SECRET |
Bearer token sent by Vercel with cron requests to authenticate the /api/cron/publish endpoint |
Development — set AUTH_ADMIN_PASSWORD_PLAIN to your password as plain text:
AUTH_ADMIN_PASSWORD_PLAIN=mysecretpasswordThe application checks AUTH_ADMIN_PASSWORD_PLAIN first. If it is set, AUTH_ADMIN_PASSWORD is ignored entirely. Do not use this in production.
Production — generate a bcrypt hash and set AUTH_ADMIN_PASSWORD. Leave AUTH_ADMIN_PASSWORD_PLAIN unset.
node -e "import('bcryptjs').then(m => m.hash('yourpassword', 12).then(console.log))"Paste the output into your environment:
AUTH_ADMIN_PASSWORD=$2b$12$examplehashoutputhereThe admin panel is accessible at /admin and requires authentication. All routes under /admin are protected by NextAuth middleware. The panel is organized into ten sections.
The dashboard provides a quick overview of the site's current state:
- Aggregate stat cards: total published posts, active subscribers, total page views, approved comments, pending comments, and unread messages
- Top 5 posts ranked by view count
- 5 most recently created posts with status badges
- Latest 3 pending comments for quick moderation
- Latest 3 unread contact messages
- Quick action buttons for creating a new post and reviewing all pending comments
- Data table with search, category filter, author filter, and draft/published status filter
- Sortable columns: title, view count, creation date
- Bulk select with bulk delete and bulk publish operations
- CSV export of the current filtered post list
- Create and edit posts using the Tiptap rich text editor
- Cover image upload via Cloudinary
- Post metadata: category assignment, tag input, series membership and series order number
- Draft/published toggle and scheduled publishing date and time picker
- Excerpt field (max 160 characters) used as the page meta description
- Preview button opens the secure draft preview URL in a new tab without requiring the reader to log in
- Create, edit, and delete author profiles
- Fields: name, slug, bio, avatar (Cloudinary upload)
- Social links: Twitter, Instagram, LinkedIn, GitHub, personal website
- Authors are assigned to posts in the post editor
- Post count displayed per author in the listing
- Create, edit, and delete categories
- Fields: name, slug, description
- Post count displayed per category
- Categories are used for filtering on the blog listing page and for related post suggestions
- Read-only aggregated view of all tags used across published posts
- Post count displayed per tag
- Tags are not a separate database entity — they are managed directly in the post editor as a comma-separated input and stored as an array on the Post document
- Create, edit, and delete post series
- Fields: name, slug, description
- Assign posts to a series and set their order number from within the post editor
- Series post list with drag-reorder support in the series detail view
- Post count displayed per series
- Full paginated list of all submitted comments
- Filter by status: pending, approved, rejected
- Search by commenter name, email, or content
- Approve or reject individual comments
- Approved comments are immediately visible to readers on the post page
- Email notification sent to the admin when a new comment is submitted
- Email notification sent to the commenter when their comment is approved
- Inbox for all contact form submissions
- Filter by read status: all, unread, read
- Search by sender name, email, or subject
- Mark individual messages as read or unread
- Reply to messages directly from the message detail view — reply is sent via Nodemailer to the sender's email address
- Paginated list of all newsletter subscribers
- Filter by status: active, unsubscribed
- Search by email address
- Manually unsubscribe or reactivate individual subscribers
- Export the full subscriber list as a CSV file
- Send a newsletter campaign to all active subscribers from the admin panel — enter a custom subject and body, or link to a recent post
The settings page controls all site-wide configuration. Changes take effect immediately and invalidate the settings cache.
| Setting | Description |
|---|---|
| Site Name | Displayed in the browser tab, navbar, and outgoing emails. Max 60 characters |
| Favicon | URL or Cloudinary upload. Recommended size: 32×32 or 64×64 px (PNG, ICO, or SVG) |
| Welcome Title | Large heading displayed in the homepage hero section |
| Welcome Description | Tagline displayed beneath the welcome title. Max 200 characters |
| Twitter / X | Profile URL — displayed in the footer and about page |
| GitHub | Profile URL |
| Profile URL | |
| Profile URL | |
| YouTube | Channel URL |
| Page URL | |
| Footer Text | Copyright or custom text displayed at the bottom of every page |
| Default Meta Description | Fallback meta description for pages without specific content. Max 160 characters |
| Default OG Image | Fallback image for social sharing. Recommended size: 1200×630 px |
| Google Analytics ID | Measurement ID in G-XXXXXXXXXX format. Leave blank to disable Analytics |
All public pages are server-rendered with ISR where applicable and include full SEO metadata, Open Graph tags, and JSON-LD structured data.
- Welcome section displaying the site title and description from settings
- Featured posts carousel — posts marked as
featured: truein the editor - Recent posts grid showing the 6 latest non-featured published posts
- Newsletter signup form with rate-limited submission
- Contact call-to-action section
- Paginated list of all published posts
- Filter by category via URL query parameter
- Sort by newest, oldest, or most viewed
- Toggle between grid and list view
- Post cards include cover image, title, excerpt, author name, publication date, category badge, and estimated reading time
- Full article rendered from Tiptap HTML output
- Auto-generated table of contents anchored to headings
- Series navigation bar when the post belongs to a series — links to the previous and next post in order, with a link to the full series
- Author card with bio, avatar, and social links
- Tag list with links to the corresponding tag detail pages
- Heart reaction button — increments up to 3 times, triggers canvas-confetti and a sound effect on the third click
- Related posts by category (up to 3)
- Comment section: approved comments displayed in a threaded layout, new comment submission form with validation
- Share buttons: copy link and native Web Share API
- Bookmark toggle (persisted to
localStorage) - View counter displayed in the post header (incremented server-side)
- Previous and next post navigation at the bottom
- Grid of all author profiles
- Each card displays the author's avatar or initials fallback, name, bio excerpt, and post count
- Author bio, avatar, and social links
- Paginated list of all posts by this author
- Grid of all categories
- Each card displays the category name, description, and post count
- Paginated list of all published posts in the selected category
- All tags rendered as clickable pill badges
- Post count displayed on each badge
- Paginated list of all published posts with the selected tag
- Grid of all post series
- Each card displays the series name, description, and post count
- Ordered list of all posts in the series
- Each entry shows the post number in the series, title, excerpt, and publication date
- Search input field with URL-based query state
- Results fetched from
/api/searchusing the MongoDB full-text index - Results ranked by relevance score
- Up to 8 results returned, displayed in a post card grid
- Client-side page — no server-side state
- Reads bookmark IDs stored in
localStorageand fetches the corresponding posts - Displays bookmarked posts in a grid or list view
- Individual bookmark removal from the page
- Empty state with a link to the blog listing
- Contact form with fields: name, email, subject, and message
- Zod validation on the client and server
- Rate-limited at 3 submissions per hour per IP
- Submission stored in the Messages collection and an email notification sent to the admin
- Static page with site and author information
- Auto-generated RSS 2.0 feed of all published posts
- Includes title, excerpt, link, author, category, and publication date per item
- Returns the 20 most recent published posts
/sitemap.xml— dynamically generated from the database; includes all published post slugs, category pages, tag pages, series pages, and author pages with appropriate priority and change frequency values/robots.txt— allows all public routes; disallows/admin,/api, and/login
- Push the repository to GitHub.
- Import the project at vercel.com/new.
- Add all environment variables in the Vercel project settings under Settings > Environment Variables.
- Set
NEXT_PUBLIC_APP_URLto the production domain Vercel assigns or your custom domain. - Deploy — Vercel runs
pnpm buildautomatically.
| Command | Description |
|---|---|
pnpm dev |
Start the development server at http://localhost:3000 |
pnpm build |
Create a production build |
pnpm start |
Start the production server |
pnpm lint |
Run ESLint across the project |
pnpm analyze |
Build with bundle analyzer enabled — outputs client and server reports |
PureBlog can be deployed to any platform that supports Node.js. When deploying outside of Vercel, note the following:
Cron jobs — the vercel.json cron configuration is Vercel-specific. On other platforms, configure an external cron service (such as cron-job.org) to send a GET request to https://yourdomain.com/api/cron/publish every hour with the header Authorization: Bearer YOUR_CRON_SECRET.
Rate limiting — the in-process rate limiter resets on every process restart. This is acceptable for single-instance deployments. For multi-replica or containerized environments, replace the in-process Map in lib/rate-limit.ts with a Redis-backed store.
PureBlog uses a single Vercel cron job defined in vercel.json:
| Route | Schedule | Description |
|---|---|---|
/api/cron/publish |
0 * * * * (hourly) |
Publishes all posts whose scheduledAt date has passed and status is draft |
To use scheduled publishing, set a future publish date and time in the post editor and save the post with draft status. The cron job transitions it to published within the next hour and optionally sends a newsletter to active subscribers.
The endpoint requires a CRON_SECRET Bearer token. Vercel sends this header automatically on each cron invocation. To call the endpoint manually from an external scheduler:
curl -X GET https://yourdomain.com/api/cron/publish \
-H "Authorization: Bearer YOUR_CRON_SECRET"This project is licensed under the MIT License. See the LICENSE file for details.
Semih Keçecioğlu
- GitHub: github.com/semihkececioglu
- Twitter / X: x.com/semihkececioglu
If you find this project useful, consider giving it a star on GitHub.
















