
Complete Blog Post: How to Build a Blog with Next.js 15 and MongoDB
Introduction
In this comprehensive tutorial, we'll build a complete, production-ready blog system using Next.js 15, MongoDB, and modern best practices. By the end of this guide, you'll have a fully functional blog with a powerful admin panel, SEO optimization, and cloud storage integration.
What you'll build:
- A lightning-fast blog with Next.js 15 App Router
- MongoDB database for content management
- Rich text editor for creating posts
- SEO-optimized pages with meta tags and schema markup
- Cloudflare R2 for media storage and CDN
- Secure authentication system
- Responsive design with Tailwind CSS
This tutorial is perfect for developers who want to create a professional blog platform from scratch, whether for personal use, clients, or as a SaaS product.
Prerequisites
Before we begin, make sure you have:
- Node.js 18+ installed on your machine
- Basic Next.js knowledge (routes, components, API routes)
- MongoDB account (we'll use MongoDB Atlas - free tier)
- Cloudflare account (for R2 storage - free tier)
- Code editor (VS Code recommended)
- Terminal/Command line familiarity
- Git installed (optional but recommended)
Helpful (but not required):
- Understanding of React hooks
- Experience with Tailwind CSS
- Basic knowledge of databases
- Familiarity with REST APIs
Step 1: Setup Next.js Project
Let's create a new Next.js 15 project with all the necessary configurations.
1.1 Create Next.js App
Open your terminal and run:
npx create-next-app@latest blog-cms
When prompted, select these options:
✔ Would you like to use TypeScript? … No
✔ Would you like to use ESLint? … Yes
✔ Would you like to use Tailwind CSS? … Yes
✔ Would you like to use `src/` directory? … No
✔ Would you like to use App Router? … Yes
✔ Would you like to customize the default import alias (@/*)? … No
1.2 Navigate to Project
cd blog-cms
1.3 Install Dependencies
Install all required packages:
# Database & Auth
npm install mongoose next-auth@beta bcryptjs
# AWS SDK for Cloudflare R2 (S3-compatible)
npm install @aws-sdk/client-s3
# Rich Text Editor
npm install lexical @lexical/react @lexical/rich-text @lexical/utils
# Form Handling
npm install react-hook-form zod @hookform/resolvers
# UI Components
npm install lucide-react date-fns
# Image Optimization
npm install sharp
# Utilities
npm install slugify
1.4 Install Shadcn UI
Initialize Shadcn UI for beautiful components:
npx shadcn-ui@latest init
Select these options:
✔ Would you like to use TypeScript? … no
✔ Which style would you like to use? › Default
✔ Which color would you like to use as base color? › Slate
✔ Where is your global CSS file? › app/globals.css
✔ Would you like to use CSS variables for colors? › yes
✔ Where is your tailwind.config.js located? › tailwind.config.js
✔ Configure the import alias for components? › @/components
✔ Configure the import alias for utils? › @/lib/utils
✔ Are you using React Server Components? › yes
Install essential Shadcn components:
npx shadcn-ui@latest add button
npx shadcn-ui@latest add input
npx shadcn-ui@latest add textarea
npx shadcn-ui@latest add select
npx shadcn-ui@latest add label
npx shadcn-ui@latest add card
npx shadcn-ui@latest add table
npx shadcn-ui@latest add dialog
npx shadcn-ui@latest add dropdown-menu
npx shadcn-ui@latest add toast
1.5 Project Structure
Your project structure should look like this:
blog-cms/
├── app/
│ ├── (public)/ # Public routes
│ │ ├── page.jsx # Home page
│ │ └── blog/
│ │ ├── [slug]/
│ │ │ └── page.jsx
│ │ └── page.jsx
│ ├── admin/ # Admin panel
│ │ ├── layout.jsx
│ │ ├── dashboard/
│ │ ├── posts/
│ │ ├── media/
│ │ └── settings/
│ ├── api/ # API routes
│ │ ├── auth/
│ │ ├── posts/
│ │ ├── media/
│ │ └── settings/
│ ├── layout.jsx # Root layout
│ └── globals.css
├── components/
│ ├── admin/
│ ├── blog/
│ └── ui/ # Shadcn components
├── lib/
│ ├── db.js # MongoDB connection
│ ├── models/ # Mongoose models
│ ├── utils/
│ └── r2.js # Cloudflare R2 helper
├── middleware.js # Auth & redirects
├── .env.local # Environment variables
├── package.json
└── next.config.js
Step 2: Configure Environment Variables
2.1 Create .env.local File
In your project root, create .env.local:
touch .env.local
2.2 Add Environment Variables
Open .env.local and add:
# MongoDB Connection
MONGODB_URI=your-mongodb-uri-here
# NextAuth Configuration
NEXTAUTH_SECRET=your-secret-here
NEXTAUTH_URL=http://localhost:3000
# Cloudflare R2 Storage
CLOUDFLARE_ACCOUNT_ID=your-account-id
CLOUDFLARE_R2_ACCESS_KEY_ID=your-access-key
CLOUDFLARE_R2_SECRET_ACCESS_KEY=your-secret-key
CLOUDFLARE_R2_BUCKET_NAME=your-bucket-name
CLOUDFLARE_R2_PUBLIC_URL=https://your-r2-url.com
# Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
NEXT_PUBLIC_SITE_NAME=My Blog
2.3 Generate NextAuth Secret
Generate a secure secret:
openssl rand -base64 32
Copy the output and paste it as NEXTAUTH_SECRET.
2.4 Add .env.local to .gitignore
Make sure .env.local is in your .gitignore:
# .gitignore
.env.local
.env*.local
Step 3: Setup MongoDB Database
3.1 Create MongoDB Atlas Account
- Go to https://www.mongodb.com/cloud/atlas
- Sign up for free account
- Create a new cluster (M0 Free tier)
- Choose your closest region
3.2 Create Database User
- Go to Database Access
- Click Add New Database User
- Choose Password authentication
- Username:
blogadmin - Password: Generate strong password
- Database User Privileges: Read and write to any database
- Click Add User
3.3 Whitelist IP Address
- Go to Network Access
- Click Add IP Address
- Click Allow Access from Anywhere (for development)
- Or add your specific IP
- Click Confirm
3.4 Get Connection String
- Go to Database → Connect
- Choose Connect your application
- Driver: Node.js, Version: 5.5 or later
- Copy the connection string:
mongodb+srv://blogadmin:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority
- Replace
<password>with your actual password - Add database name after
.net/:
mongodb+srv://blogadmin:yourpassword@cluster0.xxxxx.mongodb.net/blog-cms?retryWrites=true&w=majority
- Paste this into your
.env.localasMONGODB_URI
3.5 Create Database Connection File
Create lib/db.js:
// lib/db.js
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI;
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect() {
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then((mongoose) => {
console.log('✅ MongoDB connected successfully');
return mongoose;
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}
export default dbConnect;
Step 4: Create Database Models
4.1 Create User Model
Create lib/models/User.js:
// lib/models/User.js
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema({
name: {
type: String,
required: true,
},
email: {
type: String,
required: true,
unique: true,
lowercase: true,
},
password: {
type: String,
required: true,
},
role: {
type: String,
enum: ['admin', 'editor'],
default: 'editor',
},
createdAt: {
type: Date,
default: Date.now,
},
});
export default mongoose.models.User || mongoose.model('User', UserSchema);
4.2 Create Post Model
Create lib/models/Post.js:
// lib/models/Post.js
import mongoose from 'mongoose';
const PostSchema = new mongoose.Schema({
// Basic Info
title: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
unique: true,
},
excerpt: String,
content: {
type: String,
required: true,
},
// SEO Meta
metaTitle: String,
metaDescription: String,
metaKeywords: [String],
canonicalUrl: String,
// Open Graph Tags
ogTags: {
title: String,
description: String,
image: String,
url: String,
type: String,
siteName: String,
locale: String,
},
// Twitter Cards
twitterCards: {
card: String,
title: String,
description: String,
image: String,
site: String,
creator: String,
},
// Schema Markup
schema: mongoose.Schema.Types.Mixed,
pageType: {
type: String,
default: 'BlogPosting',
},
// Media
featuredImage: {
url: String,
alt: String,
title: String,
caption: String,
description: String,
},
// Taxonomy
category: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Category',
},
tags: [String],
// Settings
language: {
type: String,
default: 'en',
},
featured: {
type: Boolean,
default: false,
},
nofollow: {
type: Boolean,
default: true,
},
// Publishing
status: {
type: String,
enum: ['draft', 'published'],
default: 'draft',
},
publishedAt: Date,
// Meta
author: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true,
},
views: {
type: Number,
default: 0,
},
createdAt: {
type: Date,
default: Date.now,
},
updatedAt: {
type: Date,
default: Date.now,
},
});
// Update updatedAt on save
PostSchema.pre('save', function(next) {
this.updatedAt = Date.now();
next();
});
// Index for better query performance
PostSchema.index({ slug: 1 });
PostSchema.index({ status: 1, publishedAt: -1 });
export default mongoose.models.Post || mongoose.model('Post', PostSchema);
4.3 Create Category Model
Create lib/models/Category.js:
// lib/models/Category.js
import mongoose from 'mongoose';
const CategorySchema = new mongoose.Schema({
name: {
type: String,
required: true,
},
slug: {
type: String,
required: true,
unique: true,
},
description: String,
metaTitle: String,
metaDescription: String,
createdAt: {
type: Date,
default: Date.now,
},
});
export default mongoose.models.Category || mongoose.model('Category', CategorySchema);
4.4 Create Media Model
Create lib/models/Media.js:
// lib/models/Media.js
import mongoose from 'mongoose';
const MediaSchema = new mongoose.Schema({
filename: {
type: String,
required: true,
},
url: {
type: String,
required: true,
},
customUrl: String,
r2Key: String, // Key in R2 storage
// Metadata
alt: String,
title: String,
caption: String,
description: String,
// File Info
mimeType: String,
size: Number,
width: Number,
height: Number,
uploadedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
},
createdAt: {
type: Date,
default: Date.now,
},
});
export default mongoose.models.Media || mongoose.model('Media', MediaSchema);
4.5 Create Settings Model
Create lib/models/Settings.js:
// lib/models/Settings.js
import mongoose from 'mongoose';
const SettingsSchema = new mongoose.Schema({
// Branding
favicon: String,
logo: String,
siteName: {
type: String,
default: 'My Blog',
},
// Title Format
titleSeparator: {
type: String,
default: '|',
},
titleFormat: {
type: String,
default: 'PageTitle | SiteName',
},
// Code Injection
analyticsCode: String,
searchConsoleCode: String,
customHeadCode: String,
customBodyCode: String,
// SEO Files
robotsTxt: {
type: String,
default: `User-agent: *
Allow: /
Sitemap: https://yourdomain.com/sitemap.xml`,
},
// Default Meta
defaultMetaDescription: String,
defaultOgImage: String,
updatedAt: {
type: Date,
default: Date.now,
},
});
export default mongoose.models.Settings || mongoose.model('Settings', SettingsSchema);
4.6 Create Redirect Model
Create lib/models/Redirect.js:
// lib/models/Redirect.js
import mongoose from 'mongoose';
const RedirectSchema = new mongoose.Schema({
from: {
type: String,
required: true,
unique: true,
},
to: {
type: String,
required: true,
},
type: {
type: Number,
enum: [301, 302],
default: 301,
},
active: {
type: Boolean,
default: true,
},
createdAt: {
type: Date,
default: Date.now,
},
});
export default mongoose.models.Redirect || mongoose.model('Redirect', RedirectSchema);
Step 5: Setup Cloudflare R2 Storage
5.1 Create R2 Bucket
- Go to Cloudflare Dashboard
- Navigate to R2 from sidebar
- Click Create Bucket
- Bucket name:
blog-media - Location: Automatic
- Click Create Bucket
5.2 Enable Public Access
- Go to your bucket settings
- Click Settings
- Find Public Access section
- Click Allow Access
- Note your R2.dev URL:
https://pub-xxxxx.r2.dev
5.3 Create API Token
- Click Manage R2 API Tokens
- Click Create API Token
- Token name:
blog-storage - Permissions: Admin Read & Write
- Click Create API Token
- SAVE both Access Key ID and Secret Access Key
5.4 Create R2 Helper
Create lib/r2.js:
// lib/r2.js
import { S3Client, PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import sharp from 'sharp';
const r2Client = new S3Client({
region: "auto",
endpoint: `https://${process.env.CLOUDFLARE_ACCOUNT_ID}.r2.cloudflarestorage.com`,
credentials: {
accessKeyId: process.env.CLOUDFLARE_R2_ACCESS_KEY_ID,
secretAccessKey: process.env.CLOUDFLARE_R2_SECRET_ACCESS_KEY,
},
});
// Upload file to R2
export async function uploadToR2(file, folder = 'uploads') {
const buffer = await file.arrayBuffer();
// Generate unique filename
const timestamp = Date.now();
const sanitizedName = file.name.replace(/[^a-zA-Z0-9.-]/g, '-');
const filename = `${folder}/${timestamp}-${sanitizedName}`;
// Optimize image if it's an image
let finalBuffer = Buffer.from(buffer);
let width, height;
if (file.type.startsWith('image/')) {
const optimized = await sharp(Buffer.from(buffer))
.resize(2000, 2000, { fit: 'inside', withoutEnlargement: true })
.jpeg({ quality: 85 })
.toBuffer({ resolveWithObject: true });
finalBuffer = optimized.data;
width = optimized.info.width;
height = optimized.info.height;
}
// Upload to R2
await r2Client.send(new PutObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME,
Key: filename,
Body: finalBuffer,
ContentType: file.type,
CacheControl: 'public, max-age=31536000',
}));
return {
url: `${process.env.CLOUDFLARE_R2_PUBLIC_URL}/${filename}`,
key: filename,
size: finalBuffer.length,
mimeType: file.type,
width,
height,
};
}
// Delete from R2
export async function deleteFromR2(key) {
await r2Client.send(new DeleteObjectCommand({
Bucket: process.env.CLOUDFLARE_R2_BUCKET_NAME,
Key: key,
}));
}
Step 6: Setup Authentication
6.1 Create Seed Script for Admin User
Create lib/seed.js:
// lib/seed.js
import bcrypt from 'bcryptjs';
import dbConnect from './db.js';
import User from './models/User.js';
async function seedAdminUser() {
await dbConnect();
const adminExists = await User.findOne({ email: 'admin@blog.com' });
if (!adminExists) {
const hashedPassword = await bcrypt.hash('admin123', 10);
await User.create({
name: 'Admin User',
email: 'admin@blog.com',
password: hashedPassword,
role: 'admin',
});
console.log('✅ Admin user created');
console.log('Email: admin@blog.com');
console.log('Password: admin123');
} else {
console.log('ℹ️ Admin user already exists');
}
process.exit(0);
}
seedAdminUser();
6.2 Add Seed Script to package.json
Open package.json and add:
{
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"seed": "node lib/seed.js"
}
}
6.3 Run Seed Script
npm run seed
You should see:
✅ MongoDB connected successfully
✅ Admin user created
Email: admin@blog.com
Password: admin123
6.4 Create NextAuth Configuration
Create app/api/auth/[...nextauth]/route.js:
// app/api/auth/[...nextauth]/route.js
import NextAuth from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import bcrypt from "bcryptjs";
import dbConnect from "@/lib/db";
import User from "@/lib/models/User";
export const authOptions = {
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
await dbConnect();
const user = await User.findOne({ email: credentials.email });
if (!user) {
throw new Error("No user found with this email");
}
const isValid = await bcrypt.compare(credentials.password, user.password);
if (!isValid) {
throw new Error("Invalid password");
}
return {
id: user._id.toString(),
email: user.email,
name: user.name,
role: user.role,
};
}
})
],
session: {
strategy: "jwt",
},
pages: {
signIn: "/admin/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.role = user.role;
token.id = user.id;
}
return token;
},
async session({ session, token }) {
if (session.user) {
session.user.role = token.role;
session.user.id = token.id;
}
return session;
}
},
secret: process.env.NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };
6.5 Create Middleware for Protection
Create middleware.js in project root:
// middleware.js
import { getToken } from "next-auth/jwt";
import { NextResponse } from "next/server";
export async function middleware(request) {
const path = request.nextUrl.pathname;
// Protect admin routes
if (path.startsWith('/admin') && !path.startsWith('/admin/login')) {
const token = await getToken({
req: request,
secret: process.env.NEXTAUTH_SECRET
});
if (!token) {
return NextResponse.redirect(new URL('/admin/login', request.url));
}
}
return NextResponse.next();
}
export const config = {
matcher: ['/admin/:path*']
};
Step 7: Create Admin Login Page
7.1 Create Login Page
Create app/admin/login/page.jsx:
// app/admin/login/page.jsx
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const router = useRouter();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result.error) {
setError(result.error);
} else {
router.push("/admin/dashboard");
router.refresh();
}
} catch (error) {
setError("An error occurred. Please try again.");
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>Admin Login</CardTitle>
<CardDescription>Enter your credentials to access the admin panel</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="admin@blog.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<Input
id="password"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}
Step 8: Test Your Setup
8.1 Start Development Server
npm run dev
8.2 Test Database Connection
The console should show:
✅ MongoDB connected successfully
8.3 Test Login
- Visit: http://localhost:3000/admin/login
- Enter:
- Email:
admin@blog.com - Password:
admin123
- Email:
- Click Sign In
- You should be redirected to
/admin/dashboard
Step 9: Next Steps
Congratulations! You've successfully set up the foundation for your blog CMS. In the next parts of this series, we'll cover:
Part 2: Building the Admin Dashboard
- Create dashboard layout with sidebar
- Build post listing page
- Implement post statistics
Part 3: Rich Text Editor Integration
- Integrate Lexical editor
- Add formatting toolbar
- Implement image uploads
Part 4: SEO Optimization
- Meta tags management
- Open Graph implementation
- Schema markup builder
Part 5: Blog Frontend
- Create blog listing page
- Build single post page
- Implement pagination
Part 6: Deployment
- Deploy to Vercel
- Configure production environment
- Set up custom domain
Troubleshooting
MongoDB Connection Issues
If you get connection errors:
# Check your connection string format
mongodb+srv://username:password@cluster.mongodb.net/database-name?retryWrites=true&w=majority
# Make sure:
# 1. Password has no special characters (or URL encode them)
# 2. IP is whitelisted
# 3. Database user has correct permissions
NextAuth Errors
If authentication doesn't work:
# Make sure NEXTAUTH_SECRET is set
openssl rand -base64 32
# Check NEXTAUTH_URL matches your domain
NEXTAUTH_URL=http://localhost:3000
R2 Upload Failures
If file uploads fail:
# Verify all R2 credentials are set
# Test connection with AWS CLI or Postman
# Check bucket permissions
Conclusion
You now have a solid foundation for your Next.js blog CMS with:
✅ Next.js 15 with App Router ✅ MongoDB database with models ✅ Cloudflare R2 for media storage ✅ Secure authentication system ✅ Admin panel structure ✅ Environment configuration
The next tutorial will cover building the admin dashboard and post management system.
Full source code: GitHub Repository