A personal blog is a useful full-stack project: it combines page rendering, database design, content formatting, search metadata, and deployment. The tricky parts are not just connecting Next.js to MongoDB. You also need to keep database credentials on the server, validate content before saving it, render Markdown without enabling unsafe HTML, and ensure that only authorized users can publish.
This tutorial uses the Next.js App Router, the MongoDB Node.js driver, and a small Markdown renderer. It covers a simple post schema, server-side data access, validated publishing, metadata, pagination, and deployment considerations. Code examples are building blocks; connect them to an authentication system and deployment platform appropriate for your project.
Key takeaways
- Keep MongoDB access in server-only code and store its connection string in a private environment variable.
- Model posts with explicit fields, a unique slug, a publication status, and timestamps.
- Validate submitted data and construct database documents from allowed fields instead of saving arbitrary request bodies.
- Render Markdown with raw HTML disabled by default; do not pass untrusted content to
dangerouslySetInnerHTML. - Require verified authentication and authorization before allowing post creation or publication.
- Use bounded pagination, appropriate indexes, and production deployment settings.
1. Create the Next.js project
Create an App Router project and install the MongoDB driver, a validation library, and a Markdown renderer:
npx create-next-app@latest personal-blog
cd personal-blog
npm install mongodb zod react-markdown
Choose TypeScript if you want the examples below to match your project. The exact generated files can vary with the Next.js version and the options selected during setup.
A simple project structure might look like this:
app/
blog/
[slug]/page.tsx
page.tsx
api/
admin/posts/route.ts
lib/
mongodb.ts
posts.ts
The public blog pages can be server components. They can query MongoDB on the server and return rendered content without exposing database credentials to browser code.
2. Design a post schema
MongoDB is flexible, but a blog still benefits from a consistent document shape. Decide which fields your application supports and use them consistently:
export type Post = {
title: string;
slug: string;
excerpt: string;
content: string;
status: "draft" | "published";
createdAt: Date;
updatedAt: Date;
publishedAt: Date | null;
};
Use slug as the public URL identifier, such as my-first-post. Keep it unique so two posts cannot accidentally claim the same route. status separates drafts from public content, while publishedAt gives you a dependable publication date for display and ordering. createdAt and updatedAt record when the document was created and last changed.
Create indexes during setup or through a migration script rather than relying on a request to create them. A unique slug index enforces the route constraint in the database. A compound index can support listing published posts in reverse publication order:
await posts.createIndex({ slug: 1 }, { unique: true });
await posts.createIndex({ status: 1, publishedAt: -1, _id: -1 });
The second index reflects a common query pattern: filter by publication status, then sort by publication time and a stable tie-breaker. Confirm indexes against your actual queries as the project grows. MongoDB schema validation is also available if you want the database to enforce document rules in addition to application validation.
3. Connect to MongoDB safely
Put the connection string in a local .env.local file:
MONGODB_URI=mongodb+srv://<username>:<password>@<cluster-host>/<database>?retryWrites=true&w=majority
MONGODB_DB=personal_blog
Use the connection string provided by your MongoDB deployment rather than copying this illustrative format literally. Add .env.local to .gitignore, do not commit secrets, and do not prefix private values with NEXT_PUBLIC_. Variables with that prefix are intended to be exposed to browser bundles.
A small server-side connection module can reuse a MongoDB client during local development:
// lib/mongodb.ts
import "server-only";
import { MongoClient } from "mongodb";
const uri = process.env.MONGODB_URI;
if (!uri) {
throw new Error("MONGODB_URI is not set");
}
const options = {};
let clientPromise: Promise<MongoClient>;
declare global {
// eslint-disable-next-line no-var
var mongoClientPromise: Promise<MongoClient> | undefined;
}
if (process.env.NODE_ENV === "development") {
global.mongoClientPromise ??= new MongoClient(uri, options).connect();
clientPromise = global.mongoClientPromise;
} else {
clientPromise = new MongoClient(uri, options).connect();
}
export default clientPromise;
Then expose a database helper that stays in server-only code:
// lib/posts.ts
import "server-only";
import clientPromise from "./mongodb";
import type { Post } from "./post-types";
export async function getPostsCollection() {
const client = await clientPromise;
const databaseName = process.env.MONGODB_DB;
if (!databaseName) throw new Error("MONGODB_DB is not set");
return client.db(databaseName).collection<Post>("posts");
}
Create lib/post-types.ts for the shared type, or place the type in an appropriate server-only module. Keep database helpers out of client components. A parser or validation library can help inspect URLs and input, but it is not a substitute for server-side access controls or safe database configuration.
4. Validate publishing input and restrict access
Never save an entire request body directly to MongoDB. Define the accepted fields, validate them, and construct the document explicitly. This prevents unexpected fields—such as a client-supplied status or publishedAt—from silently becoming trusted data.
For example, validate a new post with Zod:
import { z } from "zod";
const createPostSchema = z.object({
title: z.string().trim().min(1).max(160),
slug: z
.string()
.trim()
.min(1)
.max(120)
.regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),
excerpt: z.string().trim().max(300).default(""),
content: z.string().min(1).max(100_000),
});
A publishing endpoint must verify both identity and permission on the server. For example, requireAdmin() below represents a function you must implement using a trusted authentication system that verifies the session and checks the user's authorization. It must not infer permission from a request body, query parameter, or client-side UI state.
// app/api/admin/posts/route.ts
import { NextResponse } from "next/server";
import { createPostSchema } from "@/lib/validation";
import { requireAdmin } from "@/lib/auth";
import { getPostsCollection } from "@/lib/posts";
export async function POST(request: Request) {
const admin = await requireAdmin();
if (!admin) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
let body: unknown;
try {
body = await request.json();
} catch {
return NextResponse.json({ error: "Invalid JSON" }, { status: 400 });
}
const result = createPostSchema.safeParse(body);
if (!result.success) {
return NextResponse.json({ error: "Invalid post data" }, { status: 400 });
}
const now = new Date();
const post = {
...result.data,
status: "draft" as const,
createdAt: now,
updatedAt: now,
publishedAt: null,
};
try {
await (await getPostsCollection()).insertOne(post);
return NextResponse.json({ slug: post.slug }, { status: 201 });
} catch (error) {
// A production handler should identify duplicate-key errors and return 409.
console.error("Could not create post", error);
return NextResponse.json({ error: "Could not create post" }, { status: 500 });
}
}
This example deliberately creates a draft. Publishing can be a separate authorized action that changes the status and sets publishedAt. Protect both actions, apply rate limits where appropriate, and avoid returning internal errors or database details to callers. Authentication setup is intentionally not implemented here; use a maintained authentication solution and verify its server-side authorization guidance.
5. Render Markdown without unsafe HTML
Markdown is convenient for writing posts, but rendering user-controlled content needs care. react-markdown does not enable embedded raw HTML by default. Keep that behavior: do not add a raw-HTML parsing plugin unless the content is sanitized with a suitable allowlist. Avoid dangerouslySetInnerHTML for post content.
A basic post page can render Markdown like this:
// app/blog/[slug]/page.tsx
import ReactMarkdown from "react-markdown";
import { notFound } from "next/navigation";
import { getPublishedPost } from "@/lib/posts";
type Props = {
params: Promise<{ slug: string }>;
};
export default async function BlogPostPage({ params }: Props) {
const { slug } = await params;
const post = await getPublishedPost(slug);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
{post.publishedAt && (
<time dateTime={post.publishedAt.toISOString()}>
{post.publishedAt.toLocaleDateString()}
</time>
)}
<ReactMarkdown>{post.content}</ReactMarkdown>
</article>
);
}
If you choose to support raw HTML, treat it as an explicit security decision: sanitize the resulting HTML with a maintained sanitizer and a restrictive policy before rendering it. Sanitization configuration must match the elements and attributes your blog actually needs. Also review how links and images are handled; do not build custom rendering components that trust arbitrary URLs or attributes without validation.
6. Fetch only published posts
Public queries should include status: "published" in the database filter. Do not fetch drafts and rely only on hiding them in the component.
// Add to lib/posts.ts
export async function getPublishedPost(slug: string) {
const posts = await getPostsCollection();
return posts.findOne({ slug, status: "published" });
}
This query returns no draft to the public route, even if someone guesses its slug. The same rule applies to list pages, metadata lookups, feeds, and any API that exposes posts.
7. Add pagination to the blog index
For a small personal blog, page-number pagination with skip and limit is easy to understand. Validate the requested page, cap the page size, and query only published posts:
const PAGE_SIZE = 10;
export async function getPublishedPosts(page: number) {
const posts = await getPostsCollection();
const safePage = Number.isInteger(page) ? Math.max(1, page) : 1;
const [items, total] = await Promise.all([
posts
.find({ status: "published" })
.sort({ publishedAt: -1, _id: -1 })
.skip((safePage - 1) * PAGE_SIZE)
.limit(PAGE_SIZE)
.project({ title: 1, slug: 1, excerpt: 1, publishedAt: 1 })
.toArray(),
posts.countDocuments({ status: "published" }),
]);
return {
items,
page: safePage,
totalPages: Math.ceil(total / PAGE_SIZE),
};
}
Use the same sort order for every page. The _id tie-breaker makes ordering deterministic when two posts share a publication timestamp. In the page component, parse the page query parameter and render links to valid neighboring pages. If the requested page is beyond the end, choose a consistent behavior such as showing an empty result or redirecting to the last page.
Offset pagination is straightforward, but large offsets can become less efficient and new posts can shift results between requests. For a larger or frequently updated collection, consider cursor-based pagination using the last post's publishedAt and _id; validate cursor input and use a matching index.
8. Add page metadata
Next.js supports route metadata through generateMetadata. Use the post title and excerpt to create a useful page title and description, and return a not-found result for drafts or missing posts just as the page itself does.
// In app/blog/[slug]/page.tsx
import type { Metadata } from "next";
export async function generateMetadata({ params }: Props): Promise<Metadata> {
const { slug } = await params;
const post = await getPublishedPost(slug);
if (!post) return { title: "Post not found" };
return {
title: post.title,
description: post.excerpt,
alternates: { canonical: `/blog/${post.slug}` },
};
}
Keep titles and descriptions specific to each post, and do not include private draft content in metadata. If you add Open Graph images or a canonical site URL, derive them from trusted application configuration rather than a value supplied by a visitor.
9. Deploy the application
A typical deployment workflow is:
- Push the project to your version-control host and connect it to a Next.js-compatible hosting provider.
- Add
MONGODB_URIandMONGODB_DBas server-side environment variables in the hosting provider's project settings. Add the same variables for preview environments only if they should access a database. - Configure MongoDB network access for the deployed application. Prefer the narrowest network access your hosting setup supports; do not expose database credentials in client code or public logs.
- Use a database user with only the permissions the application needs. Keep development, preview, and production data separated when practical.
- Deploy, then test the public index, a published post, a missing slug, pagination, and the protected publishing flow.
- Confirm that an unauthenticated request cannot create or publish a post, and that a draft is not returned by public pages or metadata.
Do not assume local development and serverless deployment have identical connection lifecycles. Follow the MongoDB driver and hosting provider guidance for connection reuse, runtime compatibility, and network configuration. Rotate credentials if they are exposed, and use the hosting provider's secret-management controls rather than committing production values.
Common mistakes and pitfalls
- Using
NEXT_PUBLIC_for a database secret: that prefix is for values intended to reach client-side code. Keep database credentials private. - Saving arbitrary request bodies: validate input and build a document from an explicit allowlist of fields.
- Trusting client-side admin controls: hiding a publish button is not authorization. Check the authenticated user's permission in the server-side handler.
- Returning drafts and hiding them in the UI: include publication status in every public database query.
- Enabling raw HTML without sanitization: ordinary Markdown rendering does not require raw HTML. Keep it disabled unless you have a clear need and a safe sanitization policy.
- Relying on a unique index alone for friendly errors: the index protects data integrity, but the application should handle duplicate slugs and return an appropriate response.
- Using unbounded pagination: cap page size and validate page values. Consider cursor pagination if the collection or offsets become large.
- Logging secrets or full request bodies: log useful operational context without credentials or potentially sensitive content.
Conclusion
A dependable personal blog is more than a database connection and a Markdown component. Define a stable post schema, keep MongoDB access server-side, validate and whitelist publishing input, and enforce authorization on every write operation. Render Markdown with raw HTML disabled, expose only published posts, and add bounded pagination and page-specific metadata. Before launch, test both normal reading flows and the security boundaries around drafts, credentials, and publishing.
