Skip to content

MPA Mini Project β€” Build a Complete Multi-Page Application from Scratch

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about MPA Mini Project. We cover key concepts, practical examples, and best practices to help you master this topic.

Build a complete MPA from scratch combining server rendering, form handling, session management, CSRF protection, SEO optimization, performance caching, accessibility, and security into one production-ready application.

What You'll Learn

By the end of this project, you will have built a production-ready MPA that incorporates all the concepts from this tutorial series: server-side rendering with templates, form validation with the PRG pattern, session-based authentication, CSRF protection, SEO meta tags, performance caching, progressive enhancement, accessibility best practices, and security hardening.

Why It Matters

Reading tutorials teaches concepts, but building a complete project solidifies your understanding. This capstone project simulates a real-world development process where you must make architectural decisions, integrate multiple concerns, debug issues, and deliver a working application to production.

Real-World Use

This project mirrors the architecture of production content management systems like WordPress, Drupal, or Shopify β€” an MPA with authentication, dynamic content, forms, and database integration. Completing this project prepares you to build real-world MPAs for clients or employers.

MPA Capstone Architecture
    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
    β”‚              Blog CMS MPA Architecture                   β”‚
    β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
    β”‚                                                          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
    β”‚  β”‚ Public  β”‚  β”‚ Admin    β”‚  β”‚ Auth    β”‚  β”‚ API      β”‚  β”‚
    β”‚  β”‚ Routes  β”‚  β”‚ Routes   β”‚  β”‚ System  β”‚  β”‚ Routes   β”‚  β”‚
    β”‚  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€  β”‚
    β”‚  β”‚ /       β”‚  β”‚ /admin   β”‚  β”‚ Login   β”‚  β”‚ Search   β”‚  β”‚
    β”‚  β”‚ /posts  β”‚  β”‚ /admin/  β”‚  β”‚ Registerβ”‚  β”‚ Comments β”‚  β”‚
    β”‚  β”‚ /posts/ β”‚  β”‚ posts    β”‚  β”‚ Logout  β”‚  β”‚ Contact  β”‚  β”‚
    β”‚  β”‚ :slug   β”‚  β”‚ /admin/  β”‚  β”‚         β”‚  β”‚          β”‚  β”‚
    β”‚  β”‚ /about  β”‚  β”‚ users    β”‚  β”‚         β”‚  β”‚          β”‚  β”‚
    β”‚  β”‚ /contactβ”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                                            β”‚
    β”‚                                                          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
    β”‚  β”‚              Server Middleware                    β”‚    β”‚
    β”‚  β”‚  Sessions β”‚ CSRF β”‚ Security Headers β”‚ Caching    β”‚    β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
    β”‚                                                          β”‚
    β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
    β”‚  β”‚              Database (PostgreSQL)                β”‚    β”‚
    β”‚  β”‚     users β”‚ posts β”‚ comments β”‚ categories        β”‚    β”‚
    β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Think of this project like building a house after learning carpentry, plumbing, and electrical work separately. Now you put it all together. Each skill you learned β€” server rendering, forms, sessions, security β€” is a trade that contributes to the final structure.

Project Overview

Build a Blog CMS MPA with the following features:

  • Public pages: home page with featured posts, blog listing with pagination, individual blog post with comments, about page, contact form
  • Admin panel: login, dashboard, create/edit/delete posts, manage comments, user profile
  • Security: CSRF protection, security headers, SQL injection prevention, input validation
  • Performance: Redis page caching, image optimization, CDN configuration
  • Accessibility: skip navigation, semantic HTML, focus management, ARIA landmarks
  • SEO: unique meta tags per page, structured data, XML sitemap, canonical URLs

Step 1: Project Setup

# Initialize project
mkdir blog-cms && cd blog-cms
npm init -y
npm install express ejs pg session express-session connect-redis
npm install helmet csurf dotenv bcrypt joi
npm install -D nodemon

# File structure:
# blog-cms/
#   src/
#     routes/        β€” Route handlers
#     views/         β€” EJS templates
#     middleware/     β€” Auth, validation, caching
#     models/        β€” Database queries
#     public/        β€” Static assets
#   server.js        β€” Entry point

Step 2: Server Setup with Middleware

const express = require('express');
const session = require('express-session');
const RedisStore = require('connect-redis').default;
const helmet = require('helmet');
const { csrfProtection } = require('./middleware/csrf');

const app = express();

// View engine
app.set('view engine', 'ejs');
app.set('views', './src/views');

// Middleware stack
app.use(helmet());
app.use(express.urlencoded({ extended: true }));
app.use(express.static('public', {
    maxAge: '1y',
    immutable: true
}));

// Session
app.use(session({
    store: new RedisStore({ client: redisClient }),
    secret: process.env.SESSION_SECRET,
    name: 'blog.sid',
    resave: false,
    saveUninitialized: false,
    cookie: {
        httpOnly: true,
        secure: true,
        sameSite: 'strict',
        maxAge: 24 * 60 * 60 * 1000
    }
}));

// CSRF protection
app.use(csrfProtection);

// Routes
app.use('/', require('./routes/public'));
app.use('/admin', require('./routes/admin'));
app.use('/api', require('./routes/api'));

// Error handler
app.use((err, req, res, next) => {
    console.error(err.stack);
    res.status(500).render('error', {
        title: 'Server Error',
        message: 'Something went wrong. Please try again.'
    });
});

Step 3: Database Models

// src/models/post.js
const { pool } = require('../db');

const Post = {
    async findAll(page = 1, limit = 10) {
        const offset = (page - 1) * limit;
        const result = await pool.query(
            'SELECT p.*, u.name as author_name, ' +
            '(SELECT COUNT(*) FROM comments c WHERE c.post_id = p.id) as comment_count ' +
            'FROM posts p JOIN users u ON p.author_id = u.id ' +
            'WHERE p.published = true ' +
            'ORDER BY p.created_at DESC LIMIT $1 OFFSET $2',
            [limit, offset]
        );

        const countResult = await pool.query(
            'SELECT COUNT(*) FROM posts WHERE published = true'
        );

        return {
            posts: result.rows,
            total: parseInt(countResult.rows[0].count),
            page,
            totalPages: Math.ceil(parseInt(countResult.rows[0].count) / limit)
        };
    },

    async findBySlug(slug) {
        const result = await pool.query(
            'SELECT p.*, u.name as author_name ' +
            'FROM posts p JOIN users u ON p.author_id = u.id ' +
            'WHERE p.slug = $1',
            [slug]
        );
        return result.rows[0] || null;
    },

    async create({ title, slug, content, excerpt, authorId, published }) {
        const result = await pool.query(
            'INSERT INTO posts (title, slug, content, excerpt, author_id, published) ' +
            'VALUES ($1, $2, $3, $4, $5, $6) RETURNING *',
            [title, slug, content, excerpt, authorId, published]
        );
        return result.rows[0];
    }
};

Step 4: Route Handlers

// src/routes/public.js β€” Home page
router.get('/', async (req, res) => {
    const page = parseInt(req.query.page) || 1;
    const { posts, total, totalPages } = await Post.findAll(page);

    res.render('home', {
        title: 'My Blog β€” Latest Posts',
        description: 'Read the latest articles about web development, programming, and technology.',
        posts,
        currentPage: page,
        totalPages,
        user: req.session.user || null
    });
});

// Blog post with comments
router.get('/posts/:slug', async (req, res) => {
    const post = await Post.findBySlug(req.params.slug);

    if (!post) {
        return res.status(404).render('error', {
            title: 'Post Not Found',
            message: 'The post you are looking for does not exist.'
        });
    }

    const comments = await Comment.findByPost(post.id);

    res.render('post', {
        title: `${post.title} β€” My Blog`,
        description: post.excerpt,
        post,
        comments,
        user: req.session.user || null,
        csrfToken: req.csrfToken()
    });
});

// Contact form
router.post('/contact', validateContactForm, async (req, res) => {
    try {
        await Contact.create(req.body);
        req.flash('success', 'Message sent successfully!');
        res.redirect(303, '/contact/thank-you');
    } catch (error) {
        req.flash('error', 'Failed to send message. Please try again.');
        res.status(422).render('contact', {
            title: 'Contact Us',
            formData: req.body,
            errors: validationErrors(req),
            user: req.session.user || null
        });
    }
});

Step 5: Testing and Deployment

# Run tests
npm test

# Build and start
NODE_ENV=production npm start

# Deploy to production
# 1. Set up PostgreSQL database
# 2. Set up Redis
# 3. Configure environment variables
# 4. Run database migrations
# 5. Start application with process manager (PM2)
# 6. Configure Nginx reverse proxy
# 7. Set up SSL certificate
# 8. Configure CDN

Common Mistakes

  1. Not handling database connection failures gracefully. Always implement connection pooling, retry logic, and user-friendly error pages.
  2. Skipping input validation on all inputs. Every user input must be validated and sanitized on the server. Never trust client-side validation alone.
  3. No rate limiting on login. Without rate limiting, attackers can brute-force passwords. Implement rate limiting with express-rate-limit.
  4. Not using environment variables for configuration. Secrets (database passwords, session secrets) must be in environment variables, not in the codebase.
  5. No automated testing. Test routes, models, validation, and middleware. Use a testing framework like Jest or Mocha.

Practice Questions

  1. What architectural decisions did you make for this project and why?
  2. How did you implement CSRF protection across all forms?
  3. What caching strategy did you use for public pages?
  4. How did you ensure the application works without JavaScript?
  5. What security measures did you implement and why?

Challenge: Extend the Blog CMS MPA with: RSS feed for blog posts, email notifications for new comments, draft/preview mode for posts (not published but viewable with a secret token), tag system with filtering, full-text search with PostgreSQL tsvector, image upload with Cloudinary integration, and social sharing buttons with proper meta tags.

FAQ

What is the best way to learn MPA development?

Build projects. Start with a simple CRUD app, then gradually add complexity: authentication, caching, security, accessibility. Each project teaches new concepts.

Should I use a framework like Laravel or Django for MPAs?

Frameworks provide structure, security, and productivity. Laravel (PHP), Django (Python), Rails (Ruby), and Next.js (Node.js) are excellent choices for MPAs.

How do I decide between MPA and SPA for a new project?

Choose MPA for content-heavy, SEO-critical, simple interactive applications. Choose SPA for highly interactive, app-like experiences. Hybrid approaches combine both.

What is the most important concept in this series?

Server rendering is the foundation of MPAs. Understanding how the server generates HTML, handles form submissions, manages sessions, and secures requests is essential.

How do I keep learning after this project?

Contribute to open-source MPA projects, read framework documentation (Express, Django, Rails), learn about advanced caching strategies, and explore Hotwire and HTMX for enhanced MPAs.

Mini Project

The project you just built IS the mini project. Extend it with: real-time search with HTMX, Turbo Drive for instant navigation, Redis caching for public pages, a REST API for external consumers, a complete test suite, and deployment to a production environment with SSL, CDN, and monitoring.

What's Next

You have completed the MPA tutorial series. Explore related topics: Server-Side Rendering to learn how SSR powers both MPAs and modern JavaScript frameworks, or HTMX for MPAs for adding dynamic behavior to your multi-page applications.

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro