Portfolio CMS API
Build a Node.js backend for a personal portfolio with project management, blog posts, authentication, image uploads, and REST endpoints
Time to implement the project: ~ 28-40 hours
- Node.js
- Express.js
- REST API
- CRUD Operations
- JWT Authentication
- MongoDB
- File Uploads
- API Design
In this intermediate Node.js project, you will build a complete backend for a personal portfolio website or developer blog. Instead of hardcoding projects and articles directly into the frontend, you will expose a REST API that allows authenticated users to create, edit, publish, delete, and retrieve portfolio items, blog posts, and uploaded media. The backend should provide a stable foundation that can later be connected to React, Vue, Svelte, or any other frontend framework.
This project is especially valuable for frontend developers because it demonstrates how content management systems actually work behind the scenes. Rather than consuming a third-party API, you will design your own endpoints, structure your own database, validate incoming requests, and implement secure authentication. Understanding these concepts makes frontend integration significantly easier and improves collaboration with backend engineers in professional environments.
Project Goal and Learning Outcomes
The primary goal of this project is to bridge the gap between frontend development and backend architecture. Many frontend developers understand how to display data but have limited experience creating the services that provide it. By building your own content API, you will learn how resources are stored, validated, queried, updated, and exposed through predictable HTTP endpoints.
Throughout the project, you will implement common backend patterns that appear in production systems. Portfolio projects and blog articles should support full CRUD operations, image uploads, publication status, timestamps, and filtering. Authentication should ensure that only authorized users can modify content while allowing public visitors to access published information without logging in.
Another important objective is learning to organize backend code professionally. Instead of placing all logic inside route files, you should separate controllers, services, middleware, models, utilities, and configuration. This modular approach improves readability and makes future maintenance or feature additions much easier.
Recommended Knowledge Before You Start
This project targets frontend developers who already have a solid understanding of JavaScript and basic familiarity with Node.js. You should know how HTTP requests work and understand JSON data structures, but previous backend experience is not required. The project is intended to demonstrate practical server-side development through realistic scenarios rather than abstract exercises.
Experience building applications with React, Vue, Angular, or Svelte will make the project even more meaningful because you can immediately imagine how your frontend would consume the API. By the end of development, you should have a backend that could power a production-quality portfolio website with minimal additional work.
- Working knowledge of Node.js, npm, modules, and asynchronous JavaScript
- Basic experience with Express.js routing and middleware concepts
- Understanding of HTTP methods, JSON requests, and REST fundamentals
- Familiarity with JavaScript promises and async/await syntax
- Introductory knowledge of MongoDB or another document-oriented database
- Experience building frontend applications that consume APIs
Core Features of the Portfolio CMS Backend
The completed backend should provide everything necessary to manage a developer portfolio without editing source files manually. Content should be stored in a database, modified through authenticated endpoints, and returned in consistent JSON structures suitable for frontend applications.
| Feature | Implementation Focus |
| Authentication system | Implement secure login and JWT-based authorization so administrative actions are available only to authenticated users. |
| Portfolio CRUD API | Create endpoints for adding, updating, deleting, and retrieving portfolio projects with structured metadata and publication status. |
| Blog management | Allow creation and editing of blog articles including title, content, tags, slug, publication date, and visibility settings. |
| Image upload support | Accept uploaded images for projects and articles while validating formats, file size, and storage paths. |
| Filtering and search | Support filtering projects by technology, category, featured status, or publication state using query parameters. |
| Pagination | Return large collections in manageable pages with metadata describing total results and current page information. |
| Consistent error handling | Provide standardized validation responses and meaningful HTTP status codes for invalid requests or missing resources. |
| Database persistence | Store all projects, articles, and uploaded metadata in MongoDB with well-structured schemas and indexes where appropriate. |
Implementation Guidance for Frontend Developers Learning Node.js
Design the API from the perspective of the frontend that will consume it. Think carefully about the JSON structure returned by each endpoint and avoid exposing unnecessary internal implementation details. A clean API contract makes frontend development significantly easier and reduces the need for repetitive data transformations.
Implement functionality incrementally rather than trying to build every endpoint at once. Start with authentication, then create portfolio CRUD operations, followed by blog management, image uploads, filtering, and pagination. Testing each feature independently helps identify problems early and keeps the overall architecture maintainable.
Treat validation and error handling as first-class features rather than optional improvements. Every endpoint should verify incoming data before interacting with the database and return clear, predictable responses when something goes wrong. This practice greatly improves both security and developer experience for frontend consumers.
- Separate routes, controllers, models, and middleware into dedicated modules
- Validate request bodies before creating or updating database records
- Use JWT middleware to protect administrative endpoints consistently
- Design response objects that remain stable even as the backend evolves
- Implement pagination before datasets become too large for efficient transfer
- Store uploaded files securely and reference them through database records
- Test invalid authentication, malformed payloads, and missing resources thoroughly
- Document endpoints so they can be consumed easily by future frontend projects
Common Mistakes When Building a Portfolio CMS API
1. Designing the API as generic CRUD instead of a content management system
A Portfolio CMS API is not just a random collection of create, read, update, and delete routes. Its purpose is to power a real portfolio website or developer blog where content has meaning: projects can be featured, posts can be drafts or published, images belong to specific resources, tags help filtering, and public pages should only expose content that is ready to be shown.
A common mistake is creating generic endpoints such as /items or /data without modeling the actual content types. This makes the frontend
harder to build because every page has to guess what the data means. A better API should clearly separate resources such as projects, blog posts, skills, experience,
achievements, media files, and profile information.
For frontend developers learning Node.js, this is one of the most important lessons. A backend should give the frontend predictable, meaningful data. If the portfolio homepage needs featured projects and recent posts, the API should make that data easy to request and easy to render.
Problematic generic model:
const itemSchema = new mongoose.Schema({
title: String,
content: String,
type: String,
image: String
});
This schema is too vague. The backend cannot clearly enforce project-specific fields, blog-specific fields, publication status, or SEO metadata.
Better portfolio content models:
type ProjectStatus = "draft" | "published" | "archived";
type PortfolioProject = {
id: string;
title: string;
slug: string;
summary: string;
description: string;
technologies: string[];
liveUrl?: string;
repositoryUrl?: string;
coverImageId?: string;
featured: boolean;
status: ProjectStatus;
createdAt: string;
updatedAt: string;
publishedAt?: string;
};
type BlogPost = {
id: string;
title: string;
slug: string;
excerpt: string;
content: string;
tags: string[];
coverImageId?: string;
status: "draft" | "published";
seoTitle?: string;
seoDescription?: string;
createdAt: string;
updatedAt: string;
publishedAt?: string;
};
Frontend-oriented API routes:
GET /api/public/profile
GET /api/public/projects
GET /api/public/projects/featured
GET /api/public/projects/:slug
GET /api/public/posts
GET /api/public/posts/:slug
POST /api/admin/projects
PATCH /api/admin/projects/:projectId
DELETE /api/admin/projects/:projectId
POST /api/admin/posts
PATCH /api/admin/posts/:postId
DELETE /api/admin/posts/:postId
Pay attention to: Model portfolio content as real content types, not generic records. Projects, posts, media, skills, and profile sections need clear fields and clear public/admin routes.
2. Mixing public content endpoints with protected admin endpoints
A portfolio website has two very different audiences. Public visitors should be able to read published projects, blog posts, profile information, and skills without logging in. The portfolio owner should be able to create, edit, delete, upload, publish, and archive content through protected admin endpoints. A common mistake is mixing these two concerns into the same routes.
If the same endpoint returns drafts to the public frontend and also supports editing content, the API becomes risky. Draft posts may appear before they are ready. Admin-only fields may leak into the public site. Frontend code may need confusing checks such as “hide this if not published.” The backend should enforce the separation.
A cleaner approach is to create public routes for published content and admin routes for authenticated content management. Public routes should never require a token and should only return safe fields. Admin routes should require JWT authentication and should return additional management fields such as status, internal notes, draft content, and edit metadata.
Problematic route:
router.get("/projects", async (request, response) => {
const projects = await Project.find();
response.json(projects);
});
router.post("/projects", async (request, response) => {
const project = await Project.create(request.body);
response.status(201).json(project);
});
The public route returns everything, including drafts and admin-only fields. The create route is also unprotected.
Better public route:
router.get("/public/projects", async (request, response, next) => {
try {
const projects = await projectService.getPublishedProjects({
featured: request.query.featured === "true",
technology: request.query.technology?.toString(),
page: Number(request.query.page || 1)
});
response.json({
data: projects.items,
meta: projects.meta
});
} catch (error) {
next(error);
}
});
Better admin route:
router.post(
"/admin/projects",
requireAuth,
validateBody(createProjectSchema),
async (request, response, next) => {
try {
const project = await projectService.createProject({
input: request.body,
authorId: request.user.id
});
response.status(201).json({
data: project
});
} catch (error) {
next(error);
}
}
);
Public projection example:
function toPublicProjectDto(project: PortfolioProject) {
return {
id: project.id,
title: project.title,
slug: project.slug,
summary: project.summary,
technologies: project.technologies,
liveUrl: project.liveUrl,
repositoryUrl: project.repositoryUrl,
coverImageUrl: project.coverImageUrl,
publishedAt: project.publishedAt
};
}
Pay attention to: Separate public reading from admin editing. Public endpoints should expose only published content, while admin endpoints require authentication and return management fields.
3. Creating slugs, publication status, and SEO fields as afterthoughts
Portfolio and blog content usually needs readable URLs. A project page such as /projects/ecommerce-dashboard is much better than
/projects/65f89a.... A common mistake is building the CMS around database IDs only and adding slugs later. This often creates duplicate slug bugs, broken
links, and inconsistent frontend routing.
Slugs should be treated as part of the content model from the beginning. They should be generated from titles, checked for uniqueness, and updated carefully. Published content also needs status fields, timestamps, and SEO metadata. Without these fields, the frontend cannot reliably build public routes, sitemap entries, article previews, or social sharing cards.
This does not mean overbuilding a full enterprise CMS. It means giving each content item the basic metadata needed for a real website: slug, status, title, excerpt, SEO description, created date, updated date, and published date.
Problematic blog model:
const postSchema = new mongoose.Schema({
title: String,
content: String
});
This is too limited for a CMS. The frontend cannot build stable routes, draft previews, SEO metadata, or filtered public lists.
Better blog schema:
const blogPostSchema = new mongoose.Schema(
{
title: {
type: String,
required: true,
trim: true
},
slug: {
type: String,
required: true,
unique: true,
index: true
},
excerpt: {
type: String,
required: true
},
content: {
type: String,
required: true
},
tags: {
type: [String],
default: []
},
status: {
type: String,
enum: ["draft", "published", "archived"],
default: "draft"
},
seoTitle: String,
seoDescription: String,
publishedAt: Date
},
{
timestamps: true
}
);
Slug helper:
function createSlug(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
}
async function createUniqueSlug(title: string): Promise<string> {
const baseSlug = createSlug(title);
let slug = baseSlug;
let counter = 1;
while (await BlogPost.exists({ slug })) {
slug = `${baseSlug}-${counter}`;
counter += 1;
}
return slug;
}
Publishing logic:
async function publishPost(postId: string) {
const post = await BlogPost.findById(postId);
if (!post) {
throw new NotFoundError("Post not found.");
}
post.status = "published";
post.publishedAt = post.publishedAt || new Date();
await post.save();
return post;
}
Pay attention to: Slugs, status, timestamps, and SEO metadata are core CMS fields. Add them early so the frontend can build stable pages and previews.
4. Handling image uploads as raw paths instead of media records
Portfolio projects and blog posts usually need images: project screenshots, cover images, avatars, logos, and article thumbnails. A common mistake is uploading a file and saving only a raw path string inside the project or post. This works for a small demo, but it becomes limiting when you need alt text, file size validation, image replacement, deletion, or multiple image sizes.
A better CMS API treats uploaded media as its own resource. The file upload endpoint should validate file type and size, store the file safely, and return a structured media object. Projects and posts can then reference media by ID or by a normalized media object. This makes the frontend easier to build because image URLs, alt text, width, height, and metadata are available in a predictable format.
This also prepares the project for future storage changes. The first version may use local uploads. Later, you may move to Cloudinary, S3, Firebase Storage, or another service. If the frontend already consumes a stable media DTO, the storage backend can change without rewriting every card component.
Problematic upload route:
router.post("/upload", upload.single("image"), (request, response) => {
response.json({
image: request.file.path
});
});
This response does not tell the frontend what the file represents, whether it is safe, or how it should be displayed.
Better media model:
type MediaAsset = {
id: string;
url: string;
fileName: string;
mimeType: string;
sizeBytes: number;
width?: number;
height?: number;
altText: string;
uploadedById: string;
createdAt: string;
};
Upload validation:
const upload = multer({
storage: multer.memoryStorage(),
limits: {
fileSize: 3 * 1024 * 1024
},
fileFilter: (request, file, callback) => {
const allowedTypes = ["image/jpeg", "image/png", "image/webp"];
if (!allowedTypes.includes(file.mimetype)) {
callback(new ValidationError("Only JPG, PNG, and WebP images are allowed."));
return;
}
callback(null, true);
}
});
Media upload endpoint:
router.post(
"/admin/media",
requireAuth,
upload.single("file"),
async (request, response, next) => {
try {
if (!request.file) {
throw new ValidationError("File is required.");
}
const media = await mediaService.createMediaAsset({
file: request.file,
altText: request.body.altText,
uploadedById: request.user.id
});
response.status(201).json({
data: media
});
} catch (error) {
next(error);
}
}
);
Frontend-friendly media response:
{
"data": {
"id": "media_1",
"url": "https://cdn.example.com/uploads/project-dashboard.webp",
"fileName": "project-dashboard.webp",
"mimeType": "image/webp",
"sizeBytes": 184233,
"altText": "Dashboard project screenshot",
"createdAt": "2026-06-19T12:00:00.000Z"
}
}
Pay attention to: Treat images as CMS media assets, not only file paths. Validate uploads and return structured metadata that the frontend can safely use.
5. Returning inconsistent response shapes and validation errors
Frontend developers need predictable API contracts. A common backend mistake is returning different shapes from different endpoints. One route returns a project
directly, another returns { result: project }, another returns { success: true, data: project }, and validation errors vary from route to
route. This makes frontend integration unnecessarily painful.
A Portfolio CMS API should use consistent response shapes. Successful responses can use { data }. Lists can use { data, meta }. Errors can use
a standard { error } object with code, message, status, and optional field details. This allows frontend code to implement reusable API clients, form error
mapping, toast messages, and loading/error states.
Validation is especially important for CMS forms. The admin panel needs to show useful messages when a title is missing, a slug is duplicated, an image is too large, or a URL is invalid. If the backend only returns “Bad request,” the frontend cannot create a good editing experience.
Problematic responses:
response.json(project);
response.json({
success: true,
result: posts
});
response.status(400).send("Invalid data");
These responses force the frontend to write custom parsing logic for every endpoint.
Better success response:
{
"data": {
"id": "project_1",
"title": "Portfolio CMS API",
"slug": "portfolio-cms-api",
"status": "published"
}
}
Better paginated response:
{
"data": [
{
"id": "post_1",
"title": "How I Built My Portfolio CMS",
"slug": "how-i-built-my-portfolio-cms"
}
],
"meta": {
"page": 1,
"limit": 10,
"totalItems": 42,
"totalPages": 5
}
}
Better validation error:
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Request body contains invalid fields.",
"status": 400,
"details": {
"title": ["Title is required."],
"slug": ["Slug is already used."],
"liveUrl": ["Live URL must be a valid URL."]
}
}
}
Centralized error middleware:
function errorHandler(error, request, response, next) {
if (error instanceof ValidationError) {
return response.status(400).json({
error: {
code: "VALIDATION_ERROR",
message: "Request body contains invalid fields.",
status: 400,
details: error.details
}
});
}
if (error instanceof NotFoundError) {
return response.status(404).json({
error: {
code: "NOT_FOUND",
message: error.message,
status: 404
}
});
}
return response.status(500).json({
error: {
code: "INTERNAL_SERVER_ERROR",
message: "Something went wrong.",
status: 500
}
});
}
Pay attention to: Stable response contracts make frontend integration much easier. Use consistent success, list, pagination, and error formats across the entire API.
6. Forgetting filtering, pagination, and public cache-friendly endpoints
A portfolio CMS may start with only three projects and two posts, but the API should still be designed as if content can grow. A common mistake is returning every record from every list endpoint. This may seem fine in development, but it becomes inefficient when the portfolio has many posts, tags, screenshots, drafts, archived projects, or admin-only records.
Public frontend pages need fast, focused endpoints. The homepage may need only featured projects and latest posts. The blog page may need pagination, tag filtering, and search. The admin panel may need draft and archived content. These are different needs, so the API should support query parameters and metadata instead of returning one oversized list.
This also helps SEO and performance. Public routes should be cache-friendly where possible. Admin routes should remain protected and dynamic. Frontend developers benefit from this because pages load faster and state management becomes simpler.
Problematic list route:
router.get("/posts", async (request, response) => {
const posts = await BlogPost.find();
response.json({
data: posts
});
});
This returns drafts, archived posts, and every record at once. It does not support public filtering or pagination.
Better query schema:
type PublicPostQuery = {
page: number;
limit: number;
tag?: string;
search?: string;
};
function parsePublicPostQuery(query: Record<string, unknown>): PublicPostQuery {
return {
page: Math.max(Number(query.page || 1), 1),
limit: Math.min(Math.max(Number(query.limit || 10), 1), 50),
tag: typeof query.tag === "string" ? query.tag : undefined,
search: typeof query.search === "string" ? query.search : undefined
};
}
Paginated public route:
router.get("/public/posts", async (request, response, next) => {
try {
const query = parsePublicPostQuery(request.query);
const result = await postService.getPublishedPosts(query);
response.setHeader("Cache-Control", "public, max-age=60");
response.json({
data: result.items,
meta: {
page: query.page,
limit: query.limit,
totalItems: result.totalItems,
totalPages: Math.ceil(result.totalItems / query.limit)
}
});
} catch (error) {
next(error);
}
});
Homepage endpoint example:
router.get("/public/homepage", async (request, response, next) => {
try {
const homepage = await contentService.getHomepageContent();
response.json({
data: {
profile: homepage.profile,
featuredProjects: homepage.featuredProjects,
latestPosts: homepage.latestPosts
}
});
} catch (error) {
next(error);
}
});
Pay attention to: Add filtering, pagination, and focused public endpoints early. Frontend pages should request exactly the content they need, not the entire CMS database.
After completing this project, you will have a robust intermediate Node.js backend that demonstrates REST API design, authentication, CRUD operations, database integration, file uploads, validation, and maintainable architecture. More importantly, you will understand how content-driven applications are built behind the scenes and how frontend clients interact with backend services in production. This project serves as an excellent bridge between frontend specialization and full-stack capability while remaining highly relevant to everyday frontend development.
Reference Implementations Worth Studying
Direct portfolio REST API reference:
FrancescoCoding - Portfolio API
This is the most direct reference from the original list because it is specifically a personal portfolio server and REST API. It is built with Node.js, Express, TypeScript, MongoDB, Mongoose, JWT authentication, bcrypt, input sanitation, Postman-based API testing, ESLint, and REST-oriented backend practices.
Pay particular attention to:
- How a portfolio website can move project data out of the frontend and into a backend API.
- How TypeScript improves backend maintainability for request objects, response objects, and database models.
- How MongoDB and Mongoose can structure project records for a personal portfolio.
- How JWT authentication protects administrative actions.
- What you would improve in your own version: media records, pagination, draft/published status, slug handling, and standardized error responses.
Use this repository as the baseline portfolio API reference. It is especially useful for frontend developers because the domain is easy to understand and maps directly to a portfolio frontend.
Portfolio admin API reference:
KunalKhandekar - Portfolio Backend API
This is the strongest replacement for a generic social-media API reference because it is directly focused on a portfolio website and admin panel. The backend is built with Node.js, Express, MongoDB, Mongoose, JWT authentication, and Zod validation. It handles content-management concerns such as project management, blog posts, achievements, experience tracking, authentication, validation, error handling, CORS, and deployment on Vercel.
When studying the code, focus on:
- How the backend supports both a public portfolio website and a private admin panel.
- How portfolio resources such as projects, blog posts, achievements, and experience can be represented as API data.
- How Zod validation improves request safety before data reaches MongoDB.
- How JWT authentication fits a single-owner portfolio CMS workflow.
- How deployment and custom-domain setup make the API feel closer to a real production service.
Use this repository as the practical admin-API comparison point. It is especially helpful if the goal is to show frontend developers how a portfolio CMS backend connects to a real admin interface.
Broader CMS architecture reference:
wastech - Content Management System
This repository is useful as a broader CMS reference rather than a portfolio-only backend. It describes a CMS API built with Node.js, NestJS, Express.js, MongoDB, Mongoose, and JWT. The feature direction includes content creation, editing, organization with tags and categories, publishing, scheduling, user roles, permissions, version control, workflow management, and SEO-related content metadata.
While reviewing this project, examine:
- How a CMS differs from a simple CRUD API because it includes publishing, organization, and roles.
- How tags, categories, and metadata make content easier for frontend pages to filter and display.
- How user roles and permissions matter when more than one person can manage content.
- How workflow and versioning ideas could be simplified for a personal portfolio CMS.
- Which parts are too advanced for the first version and should be treated as future improvements.
Use this implementation as the broader CMS concept reference. It is especially helpful for understanding how a portfolio API can evolve into a more complete headless CMS with publishing workflows and structured content management.