Build the Blog and Contact System

Add a public blog, contact form, and email confirmation to your CRM.

Introduction

30 Second Summary

Every business website needs two things visitors expect to find: a blog that shows the team is active, and a contact form that makes reaching out effortless. Without them, potential clients bounce.

In this project, you will build a complete Blog and Contact system for your Agence Amour website. You will create an article editor in the dashboard, wire up public pages that visitors can access without logging in, and set up automatic email confirmations when someone submits the contact form.

What You'll Build

Open your browser to localhost:3000/blog and see published articles displayed as cards with thumbnails and titles. Navigate to /contact, fill in a name, email, and message, hit submit, and watch a confirmation email land in your test inbox with a clickable preview link.

By the end of this project, you'll have:

  • A full article editor in your dashboard where you type a title and body, upload a thumbnail, and publish. You can toggle between draft and published, and the URL slug generates automatically from the title.
  • A public Blog page and Contact page that visitors can use without logging in. Submit the contact form and see the message appear in your dashboard's Contacts panel marked as unread.
  • An email confirmation system powered by Nodemailer and Ethereal that sends a receipt email the moment someone submits the contact form. You can click the preview URL in your terminal to read the email in your browser.
  • Secret Mission: Add individual article detail pages with slug-based dynamic routing so visitors can click "Read more" and land on /blog/our-first-event.

Are there any prerequisites?

This project builds directly on the "Secure Your CRM with Auth and Staff" project (Project 3). You need your agence-amour (Next.js) and agence-amour-api (Express TypeScript) projects running locally with JWT auth, MongoDB, and the dashboard already working.

Before We Start

Before We Start...

Before we dive in, take a moment to lock in what you are building and why it matters.

You are adding two public-facing pages (Blog and Contact) to your existing protected CRM, plus an email confirmation system that automatically sends visitors a receipt when they reach out.

Install Packages and Create the Article and Contact Models

Your API already handles authentication and the dashboard, but it has no way to store blog posts or contact form submissions. Without data models for these features, you cannot save, retrieve, or manage articles and messages.

In this step, you will install three packages that handle file uploads, email sending, and URL slug generation. Then you will create the Article and Contact models so MongoDB knows how to store and validate this data.

In this step, get ready to:
  • Install runtime and dev dependencies for file uploads, emails, and slugs.
  • Create the Article model with automatic slug generation and publish status.
  • Create the Contact model for storing visitor messages.
Install the new backend packages

Three new packages give your API the capabilities it needs for the blog and contact system. Multer handles multipart file uploads (article thumbnails). Nodemailer sends emails. slugify converts titles into URL-friendly strings.

  • In your terminal, make sure you are inside the agence-amour-api folder.
  • Install the runtime dependencies by running this command:
npm install multer nodemailer slugify
  • Install the TypeScript type definitions as dev dependencies by running:
npm install -D @types/multer @types/nodemailer

You should see packages added to your package.json with no errors in the terminal output.

Seeing permission errors?

Make sure you are running the command inside agence-amour-api (where your package.json lives), not the parent directory or the frontend project.

help me fix npm install errors in my Express project

  • Open your .env file in agence-amour-api and add these two placeholder lines at the bottom:
ETHEREAL_USER=
ETHEREAL_PASS=

Why are these empty?

Ethereal is a fake SMTP service for testing emails without sending to real addresses. Your app will auto-generate test credentials at runtime, so these placeholders stay empty for now.

Create the Article model with blog fields

The Article model defines how every blog post is stored in MongoDB. It includes a slug field that auto-generates a URL-friendly version of the title whenever you create or rename an article.

  • Create a new file called models/Article.ts inside your agence-amour-api folder.
  • Add the imports and interface at the top of the file:
import { Schema, model, Document } from 'mongoose';
import slugify from 'slugify';

export interface IArticle extends Document {
  title: string;
  slug: string;
  body: string;
  thumbnail: string;
  media: { url: string; type: string }[];
  metaTitle: string;
  metaDescription: string;
  status: 'draft' | 'published';
  author: Schema.Types.ObjectId;
}

What does this code do?

The IArticle interface tells TypeScript the exact shape of an article document. Every field (title, slug, body, thumbnail, media array, meta fields, status, author) is typed so your editor catches mistakes before you run the server.

  • Below the interface, add the schema definition, the pre-validate hook, and the export:
const articleSchema = new Schema<IArticle>({
  title: { type: String, required: true },
  slug: { type: String, unique: true },
  body: { type: String, required: true },
  thumbnail: { type: String, default: '' },
  media: [{ url: String, type: String }],
  metaTitle: { type: String, default: '' },
  metaDescription: { type: String, default: '' },
  status: { type: String, enum: ['draft', 'published'], default: 'draft' },
  author: { type: Schema.Types.ObjectId, ref: 'User' },
}, { timestamps: true });

articleSchema.pre('validate', function (next) {
  if (this.title && this.isModified('title')) {
    this.slug = slugify(this.title, { lower: true, strict: true });
  }
  next();
});

const Article = model<IArticle>('Article', articleSchema);
export default Article;

What does the pre-validate hook do?

Every time an article is saved, Mongoose runs the pre('validate') hook before validation. If the title was modified, it regenerates the slug automatically.

For example, an article titled "Our First Event" gets the slug our-first-event. The lower: true option forces lowercase. The strict: true option strips special characters.

  • Save models/Article.ts.
  • Confirm the model compiles by running:
npm run dev

Your server should start with the Server running on http://localhost:4000 message and no TypeScript errors.

Seeing a TypeScript error?

Check that import slugify from 'slugify' is at the top of the file. If TypeScript cannot find the module, confirm that npm install slugify completed successfully in the previous substep.

help me fix TypeScript errors in my Article model

✔️ Awesome, I've got everything!

Great. Make sure you have saved models/Article.ts and your server is running without errors before continuing.

ⓧ I'd like to double check the full code

Here is the complete models/Article.ts file. Compare yours against it:

import { Schema, model, Document } from 'mongoose';
import slugify from 'slugify';

export interface IArticle extends Document {
  title: string;
  slug: string;
  body: string;
  thumbnail: string;
  media: { url: string; type: string }[];
  metaTitle: string;
  metaDescription: string;
  status: 'draft' | 'published';
  author: Schema.Types.ObjectId;
}
const articleSchema = new Schema<IArticle>({
  title: { type: String, required: true },
  slug: { type: String, unique: true },
  body: { type: String, required: true },
  thumbnail: { type: String, default: '' },
  media: [{ url: String, type: String }],
  metaTitle: { type: String, default: '' },
  metaDescription: { type: String, default: '' },
  status: { type: String, enum: ['draft', 'published'], default: 'draft' },
  author: { type: Schema.Types.ObjectId, ref: 'User' },
}, { timestamps: true });

articleSchema.pre('validate', function (next) {
  if (this.title && this.isModified('title')) {
    this.slug = slugify(this.title, { lower: true, strict: true });
  }
  next();
});

const Article = model<IArticle>('Article', articleSchema);
export default Article;
Create the Contact model

The Contact model stores messages that visitors submit through the public contact form. Each message tracks a read/unread status so staff can see which messages are new.

  • Stop the running server with Ctrl+C.
  • Create a new file called models/Contact.ts and add the following code:
import { Schema, model, Document } from 'mongoose';

export interface IContact extends Document {
  name: string;
  email: string;
  phone: string;
  message: string;
  status: 'unread' | 'read';
}

const contactSchema = new Schema<IContact>({
  name: { type: String, required: true },
  email: { type: String, required: true },
  phone: { type: String, default: '' },
  message: { type: String, required: true },
  status: { type: String, enum: ['unread', 'read'], default: 'unread' },
}, { timestamps: true });

const Contact = model<IContact>('Contact', contactSchema);
export default Contact;

What does this model do?

The phone field defaults to an empty string because it is optional on the contact form. The status field starts as unread so staff can see new messages at a glance in the dashboard.

The timestamps: true option adds createdAt and updatedAt fields automatically to every document.

  • Save models/Contact.ts.

Before you restart the server, what do you expect to see? Will it start cleanly, or will there be errors?

  • Start the server again by running:
npm run dev

You should see Server running on http://localhost:4000 and Connected to MongoDB with no TypeScript errors. Both models compile correctly, confirming your new packages and schemas are wired up.

Server not starting?

If you see a module not found error for slugify or nodemailer, re-run the install commands from the first substep. Make sure you ran them inside the agence-amour-api directory.

If you see a duplicate key error about slug, that means MongoDB already has an Article collection with conflicting indexes from a previous attempt. Drop the collection with mongosh and restart.

help me fix my server startup error after adding models

✔️ Awesome, I've got everything!

Great. Make sure you have saved models/Contact.ts and your server is running cleanly.

ⓧ I'd like to double check the full code

Here is the complete models/Contact.ts file:

import { Schema, model, Document } from 'mongoose';

export interface IContact extends Document {
  name: string;
  email: string;
  phone: string;
  message: string;
  status: 'unread' | 'read';
}

const contactSchema = new Schema<IContact>({
  name: { type: String, required: true },
  email: { type: String, required: true },
  phone: { type: String, default: '' },
  message: { type: String, required: true },
  status: { type: String, enum: ['unread', 'read'], default: 'unread' },
}, { timestamps: true });

const Contact = model<IContact>('Contact', contactSchema);
export default Contact;

Your data models are ready. Next up, you will build the article editor in the dashboard so staff can create and manage blog posts using these models.

Build the Article Editor in the Dashboard

Your Article model is ready but the dashboard still shows zero articles. There is no API route to create, list, or manage articles, and no UI to interact with them.

In this step you will build the full article CRUD API with file upload support, then wire up two new dashboard pages: a list view and a creation form. By the end, staff can log in and publish their first article.

In this step, get ready to:
  • Build the article CRUD routes with thumbnail upload using Multer.
  • Create a dashboard articles list page that displays all articles.
  • Create an article creation form that posts multipart data to the API.
Set up file uploads and update server.ts

When staff upload a thumbnail image, the API needs somewhere to store it. You will create an uploads/ directory and configure Express to serve those files back to the browser as static assets.

  • In your terminal, make sure you are inside the agence-amour-api folder.
  • Create the uploads directory by running this command:
mkdir uploads
  • Confirm the directory exists by running:
ls

You should see uploads listed alongside your other folders like models and routes.

  • Open server.ts in VS Code.
  • Add the following import at the top of your file, alongside your other imports:
import path from 'path';
  • Find the line app.use(cookieParser()); and add this line below it to serve uploaded files:
app.use('/uploads', express.static(path.join(__dirname, '..', 'uploads')));

Why path.join with '..'?

When TypeScript compiles, the output lives in a dist/ folder. The __dirname at runtime points to dist/, so '..' goes one level up to the project root where uploads/ actually lives. During development with tsx, this path still resolves correctly.

  • Save server.ts.
  • Restart your API server by running:
npm run dev

You should see Connected to MongoDB and Server running on http://localhost:4000 with no errors.

Seeing a TypeScript error?

Make sure import path from 'path'; is at the top of the file alongside your other imports, not inside a function. Also check that you have no typos in the express.static line.

Help me debug my server.ts static file serving setup.

Create the article CRUD routes with file upload

Now you will replace the existing routes/articles.ts with a full CRUD implementation that uses Multer to handle thumbnail uploads.

  • Open routes/articles.ts and replace its entire contents. Start with the imports and Multer configuration:
import express, { Response, Router } from 'express';
import multer from 'multer';
import Article from '../models/Article';
import { authenticateJWT, AuthenticatedRequest } from '../middleware/auth';

const articlesRouter: Router = express.Router();

// Configure disk storage for uploaded files
const storage = multer.diskStorage({
  destination: (_req, _file, cb) => cb(null, 'uploads/'),
  filename: (_req, file, cb) => cb(null, `${Date.now()}-${file.originalname}`),
});

// Set file type and size limits
const upload = multer({
  storage,
  fileFilter: (_req, file, cb) => {
    const allowed = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4'];
    cb(null, allowed.includes(file.mimetype));
  },
  limits: { fileSize: 10 * 1024 * 1024 },
});

// All article routes require authentication
articlesRouter.use(authenticateJWT);

What does this code do?

Multer's diskStorage engine writes uploaded files to the uploads/ directory you just created. Each filename gets a timestamp prefix to avoid collisions.

The fileFilter restricts uploads to JPEG, PNG, WebP, and MP4. The limits cap sets a 10 MB maximum per file.

  • Directly below that code, add the GET routes for listing and fetching articles:
// List all articles (newest first)
articlesRouter.get('/', async (_req: AuthenticatedRequest, res: Response): Promise<void> => {
  const articles = await Article.find().sort({ createdAt: -1 }).populate('author', 'username');
  res.json(articles);
});

// Get a single article by ID
articlesRouter.get('/:id', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const article = await Article.findById(req.params.id).populate('author', 'username');
  if (!article) { res.status(404).json({ error: 'Article not found.' }); return; }
  res.json(article);
});
  • Next, add the POST route that creates an article with an optional thumbnail:
// Create a new article with optional thumbnail upload
articlesRouter.post('/', upload.single('thumbnail'), async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const { title, body, metaTitle, metaDescription, status } = req.body;
  if (!title || !body) { res.status(400).json({ error: 'Title and body are required.' }); return; }

  const article = new Article({
    title,
    body,
    metaTitle: metaTitle || '',
    metaDescription: metaDescription || '',
    status: status || 'draft',
    thumbnail: req.file ? `/uploads/${req.file.filename}` : '',
    author: req.user?.id,
  });

  const saved = await article.save();
  res.status(201).json(saved);
});

How does the thumbnail path work?

When a file is uploaded, Multer saves it to uploads/ and populates req.file. The route stores the path /uploads/1234567890-photo.jpg in the database. Because server.ts serves that folder as static files, the frontend can load the image by hitting that same path.

  • Finally, add the PUT and DELETE routes and the export:
// Update an existing article
articlesRouter.put('/:id', upload.single('thumbnail'), async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const { title, body, metaTitle, metaDescription, status } = req.body;
  const updateData: Record<string, unknown> = { title, body, metaTitle, metaDescription, status };
  if (req.file) updateData.thumbnail = `/uploads/${req.file.filename}`;

  const updated = await Article.findByIdAndUpdate(req.params.id, updateData, { new: true });
  if (!updated) { res.status(404).json({ error: 'Article not found.' }); return; }
  res.json(updated);
});

// Delete an article
articlesRouter.delete('/:id', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const deleted = await Article.findByIdAndDelete(req.params.id);
  if (!deleted) { res.status(404).json({ error: 'Article not found.' }); return; }
  res.json({ message: 'Article deleted.' });
});

export default articlesRouter;
  • Save routes/articles.ts.
  • Check your terminal where the API server is running. It should restart automatically with no errors.

Server crashing after save?

Make sure you imported multer from 'multer' (not from a subpath). Also verify the authenticateJWT and AuthenticatedRequest imports match the names your middleware/auth.ts exports.

Help me fix my articles route TypeScript errors.

✔️ Awesome, I've got everything!

Great. Make sure you have saved routes/articles.ts and your server restarted without errors.

ⓧ I'd like to double check the full code

Your routes/articles.ts should contain these four sections in order:

  • Imports, router declaration, multer storage config, upload config, and authenticateJWT middleware.
  • GET / and GET /:id handlers.
  • POST / handler with upload.single('thumbnail').
  • PUT /:id and DELETE /:id handlers, followed by export default articlesRouter.

Scroll up through the code chunks and confirm each section is present in your file.

Build the dashboard articles list page

With the API ready, you need a page in the Next.js dashboard where staff can see all articles. This page fetches from the protected /api/articles endpoint using the stored auth token.

  • Switch to your agence-amour (Next.js) project in VS Code.
  • Create the folder app/dashboard/articles/ if it does not already exist.
  • Create a new file app/dashboard/articles/page.tsx and add the component logic:
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';

interface Article { _id: string; title: string; status: string; createdAt: string; }

export default function ArticlesPage() {
  const [articles, setArticles] = useState<Article[]>([]);
  const router = useRouter();

  // Fetch all articles from the protected API
  async function fetchArticles() {
    const token = localStorage.getItem('token');
    const res = await fetch('http://localhost:4000/api/articles', {
      headers: { Authorization: `Bearer ${token}` },
    });
    if (res.ok) setArticles(await res.json());
  }

  useEffect(() => { fetchArticles(); }, []);

  // Delete an article and refresh the list
  async function handleDelete(id: string) {
    const token = localStorage.getItem('token');
    await fetch(`http://localhost:4000/api/articles/${id}`, {
      method: 'DELETE',
      headers: { Authorization: `Bearer ${token}` },
    });
    fetchArticles();
  }
  • Directly below that, still inside the same file, add the JSX return:
  return (
    <div className="p-8">
      <div className="flex justify-between items-center mb-6">
        <h1 className="text-2xl font-bold">Articles</h1>
        <button onClick={() => router.push('/dashboard/articles/new')} className="bg-blue-600 text-white px-4 py-2 rounded">New Article</button>
      </div>
      <table className="w-full border-collapse">
        <thead>
          <tr className="border-b">
            <th className="text-left p-2">Title</th>
            <th className="text-left p-2">Status</th>
            <th className="text-left p-2">Date</th>
            <th className="text-left p-2">Actions</th>
          </tr>
        </thead>
        <tbody>
          {articles.map((a) => (
            <tr key={a._id} className="border-b">
              <td className="p-2">{a.title}</td>
              <td className="p-2">{a.status}</td>
              <td className="p-2">{new Date(a.createdAt).toLocaleDateString()}</td>
              <td className="p-2"><button onClick={() => handleDelete(a._id)} className="text-red-600">Delete</button></td>
            </tr>
          ))}
        </tbody>
      </table>
    </div>
  );
}

What does this page do?

On mount, the component reads the JWT from localStorage and calls GET /api/articles. It renders the results in a table with title, status, and date columns. A New Article button links to the creation form, and each row has a Delete button.

  • Save app/dashboard/articles/page.tsx.
  • Make sure your Next.js dev server is running (npm run dev in the agence-amour folder).
  • Open your browser and navigate to http://localhost:3000/dashboard/articles.

You should see a page with the heading "Articles", a New Article button, and an empty table (no articles exist yet).

Seeing a blank page or 404?

Check that the file is saved at exactly app/dashboard/articles/page.tsx (not app/articles/page.tsx). Also verify that 'use client'; is the very first line of the file.

Help me debug my articles list page.

Build the article creation form

The list page is ready but empty. You need a form where staff can type a title, body, choose a status, and optionally attach a thumbnail image.

  • Create the folder app/dashboard/articles/new/ inside your Next.js project.
  • Create app/dashboard/articles/new/page.tsx and add the form logic:
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';

export default function NewArticlePage() {
  const [title, setTitle] = useState('');
  const [body, setBody] = useState('');
  const [status, setStatus] = useState('draft');
  const [thumbnail, setThumbnail] = useState<File | null>(null);
  const router = useRouter();

  // Submit the form as multipart/form-data
  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    const token = localStorage.getItem('token');
    const formData = new FormData();
    formData.append('title', title);
    formData.append('body', body);
    formData.append('status', status);
    if (thumbnail) formData.append('thumbnail', thumbnail);

    const res = await fetch('http://localhost:4000/api/articles', {
      method: 'POST',
      headers: { Authorization: `Bearer ${token}` },
      body: formData,
    });
    if (res.ok) router.push('/dashboard/articles');
  }
  • Directly below that code in the same file, add the JSX form:
  return (
    <div className="p-8 max-w-2xl">
      <h1 className="text-2xl font-bold mb-6">New Article</h1>
      <form onSubmit={handleSubmit} className="space-y-4">
        <input type="text" placeholder="Title" value={title} onChange={(e) => setTitle(e.target.value)} className="w-full border p-2 rounded" required />
        <textarea placeholder="Body" value={body} onChange={(e) => setBody(e.target.value)} className="w-full border p-2 rounded h-40" required />
        <input type="file" accept="image/*" onChange={(e) => setThumbnail(e.target.files?.[0] || null)} className="w-full" />
        <select value={status} onChange={(e) => setStatus(e.target.value)} className="w-full border p-2 rounded">
          <option value="draft">Draft</option>
          <option value="published">Published</option>
        </select>
        <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">Create Article</button>
      </form>
    </div>
  );
}

Why FormData instead of JSON?

When you include a file upload, the browser needs to send multipart/form-data encoding. Using FormData lets you mix text fields and the binary thumbnail in a single request. On the backend, Multer's upload.single('thumbnail') middleware parses this encoding automatically.

  • Save app/dashboard/articles/new/page.tsx.

Before you test, what do you expect to happen when you submit an article with status set to "draft"?

  • In your browser, navigate to http://localhost:3000/dashboard/articles.
  • Click the New Article button.
  • Enter Welcome to Agence Amour in the Title field.
  • Enter Our first blog post is here. in the Body field.
  • Leave the status as Draft.
  • Click Create Article.

You are redirected back to the articles list. You should see your new article in the table with status "draft" and today's date.

Article not appearing after submit?

Open DevTools and check the Network tab. If the POST to /api/articles returns 401, make sure you are logged in to the dashboard (your token must be in localStorage). If it returns 400, check that you filled in both the title and body fields.

Help me debug my article creation form.

Your article editor is working. Staff can create articles with thumbnails and see them listed in the dashboard. Next up, you will make these articles visible to the public on a Blog page that anyone can access without logging in.

Wire the Public Blog Page

In the previous step, you built a full article editor and created your first articles from the dashboard. Staff can manage content. Now it is time for visitors to actually see those articles on the public-facing /blog page.

There is a catch. Your existing /api/articles route requires JWT authentication on every request. A visitor who has not logged in has no token. You are about to hit that wall yourself and then solve it by creating a separate, unprotected route.

In this step, get ready to:
  • Confirm that the existing article route blocks unauthenticated requests.
  • Create a new public API route that serves published articles without auth.
  • Build the /blog page to display article cards from the public route.
Create a public articles API route

Before writing any new code, let's see the problem firsthand. Your backend is running from the previous step.

  • Before you run the next command, predict what the API will return when you request articles without sending a JWT token.
  • Test the existing protected route without auth by running:
curl http://localhost:4000/api/articles

You see an authentication error. The route rejects you because authenticateJWT runs on every request to /api/articles. That is the gap. Public visitors have no token and no way to get one.

Why not just remove auth from the existing route?

Removing auth from /api/articles would expose draft articles, unpublished edits, and admin-only data to anyone on the internet. Instead, you create a separate route that returns only published articles. Protected and public concerns stay cleanly separated.

The fix is a new route file that skips the auth middleware entirely and only returns published articles.

  • In the agence-amour-api project, create a new file routes/public.ts and paste this code:
import express, { Request, Response, Router } from 'express';
import Article from '../models/Article';

const publicRouter: Router = express.Router();

// Return only published articles, newest first
publicRouter.get('/articles', async (_req: Request, res: Response): Promise<void> => {
  const articles = await Article.find({ status: 'published' })
    .sort({ createdAt: -1 })
    .select('title slug thumbnail metaDescription createdAt')
    .populate('author', 'username');
  res.json(articles);
});

// Return a single published article by its slug
publicRouter.get('/articles/:slug', async (req: Request, res: Response): Promise<void> => {
  const article = await Article.findOne({ slug: req.params.slug, status: 'published' })
    .populate('author', 'username');
  if (!article) { res.status(404).json({ error: 'Article not found.' }); return; }
  res.json(article);
});

export default publicRouter;

What does this code do?

  • GET /articles filters by status: 'published' so drafts stay hidden from the public.
  • The .select() call returns only the fields a blog listing needs (title, slug, thumbnail, description, date). Full article bodies stay out of the list response.
  • GET /articles/:slug finds a single article by its URL-friendly slug for individual article pages later.
  • No authenticateJWT middleware anywhere in this file. Anyone can access these routes.

Now mount this router in your server entry point.

  • Open server.ts and find your existing import lines at the top. Add this import:
import publicRouter from './routes/public';
  • Scroll down to where you mount your routes (where you see app.use('/api/auth', authRouter)). Add this line above the protected routes:
// Public routes (no auth required)
app.use('/api/public', publicRouter);

Why mount public routes first?

Express processes middleware in order. Placing the public router above any global auth middleware ensures these routes are never accidentally protected by a future middleware addition.

  • Save routes/public.ts and server.ts.
  • Your dev server should restart automatically. Test the new public route by running:
curl http://localhost:4000/api/public/articles

You should see a JSON array. If you have no published articles yet, it returns an empty array []. If you published an article in the previous step, you see its data.

Seeing a compile error?

  • Check that the import path in server.ts matches the filename exactly: './routes/public'.
  • Confirm routes/public.ts exports publicRouter as the default export.
  • Make sure models/Article.ts exists in your project from the previous step.

Still stuck? Help me debug a compile error in my public routes file.

Build the public /blog page

The API is ready. Now the Next.js frontend needs a page that fetches from this public route and displays article cards.

  • Switch to the agence-amour frontend project.
  • Create the file app/blog/page.tsx and paste the interface and fetch logic:
import Link from 'next/link';

// Shape of an article returned by the public API
interface Article {
  _id: string;
  title: string;
  slug: string;
  thumbnail: string;
  metaDescription: string;
  createdAt: string;
  author: { username: string };
}

export default async function BlogPage() {
  // Fetch published articles from the public route (no auth needed)
  const res = await fetch('http://localhost:4000/api/public/articles', {
    cache: 'no-store',
  });
  const articles: Article[] = await res.json();

  // Empty state when no articles are published
  if (articles.length === 0) {
    return (
      <div className="max-w-4xl mx-auto p-8">
        <h1 className="text-3xl font-bold mb-6">Blog</h1>
        <p>No articles yet.</p>
      </div>
    );
  }

What does this code do?

  • This is a Server Component (no 'use client' directive). The fetch runs on the server, not in the visitor's browser.
  • The cache: 'no-store' option ensures every page load fetches fresh data from your API.
  • No auth header is included because this route is public.
  • The empty-state block renders a helpful message when no articles are published yet.
  • Now add the main return block that renders article cards. Paste this directly below the empty-state block (still inside the BlogPage function):
  // Render article cards
  return (
    <div className="max-w-4xl mx-auto p-8">
      <h1 className="text-3xl font-bold mb-6">Blog</h1>
      <div className="grid gap-6">
        {articles.map((article) => (
          <Link key={article._id} href={`/blog/${article.slug}`}
            className="block border rounded-lg overflow-hidden hover:shadow-lg transition">
            {article.thumbnail && (
              <img src={`http://localhost:4000${article.thumbnail}`}
                alt={article.title} className="w-full h-48 object-cover" />
            )}
            <div className="p-4">
              <h2 className="text-xl font-semibold">{article.title}</h2>
              <p className="text-gray-500 text-sm mt-1">
                {new Date(article.createdAt).toLocaleDateString()}
              </p>
              <p className="text-blue-600 mt-2">Read more</p>
            </div>
          </Link>
        ))}
      </div>
    </div>
  );
}

What does this code do?

  • Each article renders as a clickable card using Link from Next.js. The href points to /blog/{slug} for future article detail pages.
  • Thumbnails display only when the article has one (the && check avoids broken image tags).
  • The image src prepends http://localhost:4000 because uploaded files are served from the Express backend.
  • Save app/blog/page.tsx.
  • Make sure you have at least one published article. If all your articles are drafts, go to http://localhost:3000/dashboard/articles, edit an article, set its status to published, and save.
  • Before you open the blog page, predict: will you need to log in to see the articles?
  • Open an incognito window and navigate to http://localhost:3000/blog.

You see published article cards with thumbnails and titles. No login required. That is the public blog working end to end.

Don't see any article cards?

  • Confirm you have at least one article with status published (not draft) by checking http://localhost:4000/api/public/articles in your browser. It should return a non-empty array.
  • Check that your Next.js dev server is running in the agence-amour terminal. Restart it if needed.
  • Make sure the Express backend is also running on port 4000. The Server Component fetches from http://localhost:4000 at build time.

Still stuck? Help me debug why my /blog page shows no articles.

✔️ Awesome, I've got everything!

Great. Double check you saved both files in agence-amour-api and agence-amour.

ⓧ I'd like to double check the full code

Here is the complete routes/public.ts file at this point:

import express, { Request, Response, Router } from 'express';
import Article from '../models/Article';

const publicRouter: Router = express.Router();

// Return only published articles, newest first
publicRouter.get('/articles', async (_req: Request, res: Response): Promise<void> => {
  const articles = await Article.find({ status: 'published' })
    .sort({ createdAt: -1 })
    .select('title slug thumbnail metaDescription createdAt')
    .populate('author', 'username');
  res.json(articles);
});

// Return a single published article by its slug
publicRouter.get('/articles/:slug', async (req: Request, res: Response): Promise<void> => {
  const article = await Article.findOne({ slug: req.params.slug, status: 'published' })
    .populate('author', 'username');
  if (!article) { res.status(404).json({ error: 'Article not found.' }); return; }
  res.json(article);
});

export default publicRouter;

Scroll up and confirm your app/blog/page.tsx has both the interface/fetch block and the return block with article cards.

Your blog is live for public visitors. Next up, you will build a Contact page so potential clients can send messages directly from the website.

Build the Contact Page and Dashboard Panel

Your blog is live and displaying published articles to visitors. The next piece of a real agency website is a way for potential clients to reach out.

In this step, you will build a public Contact page that stores messages in MongoDB, plus a dashboard panel where staff can review those messages. The contact form posts to your existing public route, and the dashboard panel uses a new protected route.

In this step, get ready to:
  • Create the protected contacts API route so staff can view and manage messages.
  • Build a public Contact page where visitors can submit their name, email, and message.
  • Build a dashboard contacts panel that lists received messages with read/unread status.
Create the contact API routes and service stubs

Your server.ts already imports contactsRouter and the routes/public.ts file imports from services/mailer and services/whatsapp. Those files do not exist yet, so the server cannot compile. You need to create stub versions of the services and the contacts route file.

  • Create services/mailer.ts and services/whatsapp.ts in your agence-amour-api project with the following stub code:
// Stub - full implementation in the next step
export async function sendConfirmationEmail(to: string, name: string): Promise<void> {
  console.log(`[Mailer Stub] Confirmation email would be sent to: ${to} (${name})`);
}
// WhatsApp notification placeholder
// Replace with WhatsApp Business API integration during VPS deployment
export async function sendWhatsAppNotification(phone: string, message: string): Promise<void> {
  console.log(`[WhatsApp Placeholder] Notification would be sent to: ${phone}`);
  console.log(`[WhatsApp Placeholder] Message: ${message}`);
  // TODO: Integrate WhatsApp Business API here
  // Example providers: Twilio WhatsApp API, Meta WhatsApp Business Platform
}

Why are these stubs?

The mailer stub lets your server compile now. You will replace it with a full Nodemailer implementation that sends real confirmation emails in the next step. The WhatsApp file is a permanent placeholder until you deploy to a VPS with a real WhatsApp Business API integration.

  • Create routes/contacts.ts with the protected contacts route by adding this code:
import express, { Response, Router } from 'express';
import Contact from '../models/Contact';
import { authenticateJWT, requireClearance, AuthenticatedRequest } from '../middleware/auth';

const contactsRouter: Router = express.Router();

// Only Level 2+ staff can view and manage contact messages
contactsRouter.use(authenticateJWT);
contactsRouter.use(requireClearance(2));

// Get all contact messages, newest first
contactsRouter.get('/', async (_req: AuthenticatedRequest, res: Response): Promise<void> => {
  const contacts = await Contact.find().sort({ createdAt: -1 });
  res.json(contacts);
});

// Mark a contact message as read
contactsRouter.put('/:id/read', async (req: AuthenticatedRequest, res: Response): Promise<void> => {
  const updated = await Contact.findByIdAndUpdate(req.params.id, { status: 'read' }, { new: true });
  if (!updated) { res.status(404).json({ error: 'Contact not found.' }); return; }
  res.json(updated);
});

export default contactsRouter;

What does this route do?

This router handles the staff-facing side of contact messages. The requireClearance(2) middleware ensures only Level 2+ staff can access these endpoints.

GET / returns all messages sorted newest first. PUT /:id/read flips a message's status from unread to read so staff can track which messages they have reviewed.

  • Save all three new files (services/mailer.ts, services/whatsapp.ts, routes/contacts.ts).
  • Switch back to the terminal running your API server and restart it by running:
npm run dev

You should see Server running on http://localhost:4000 and Connected to MongoDB with no TypeScript errors.

Seeing a TypeScript error?

If you see a "Cannot find module" error mentioning services/mailer or services/whatsapp, confirm both files exist in the services/ folder at the same level as your routes/ folder.

If the error mentions routes/contacts, check that the file exports contactsRouter as the default export.

Help me fix a TypeScript import error in my Express server

Build the public /contact page

Visitors need a form where they can type their name, email, and message. This page posts directly to your public API route at /api/public/contact with no authentication required.

  • In your agence-amour (Next.js) project, create app/contact/page.tsx with the following code:
'use client';

import { useState } from 'react';

export default function ContactPage() {
  const [form, setForm] = useState({ name: '', email: '', phone: '', message: '' });
  const [status, setStatus] = useState<'idle' | 'success' | 'error'>('idle');

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    setStatus('idle');
    // POST to the public contact endpoint (no auth needed)
    const res = await fetch('http://localhost:4000/api/public/contact', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(form),
    });
    if (res.ok) {
      setStatus('success');
      setForm({ name: '', email: '', phone: '', message: '' });
    } else {
      setStatus('error');
    }
  };

  // Helper to update individual form fields
  const update = (field: string, value: string) => setForm({ ...form, [field]: value });

What does this code do?

The 'use client' directive makes this a Client Component because it needs useState for form state and event handlers.

The handleSubmit function sends the form data as JSON to your public contact endpoint. On success, it resets the form and shows a success message. On failure, it shows an error.

  • Still in app/contact/page.tsx, add the JSX return below the update helper function:
  return (
    <div className="max-w-xl mx-auto p-8">
      <h1 className="text-2xl font-bold mb-4">Contact Us</h1>
      {status === 'success' && <p className="text-green-600 mb-4">Message sent successfully!</p>}
      {status === 'error' && <p className="text-red-600 mb-4">Something went wrong. Please try again.</p>}
      <form onSubmit={handleSubmit} className="space-y-4">
        <input type="text" placeholder="Name" value={form.name} onChange={(e) => update('name', e.target.value)} className="w-full border p-2 rounded" required />
        <input type="email" placeholder="Email" value={form.email} onChange={(e) => update('email', e.target.value)} className="w-full border p-2 rounded" required />
        <input type="tel" placeholder="Phone (optional)" value={form.phone} onChange={(e) => update('phone', e.target.value)} className="w-full border p-2 rounded" />
        <textarea placeholder="Your message" value={form.message} onChange={(e) => update('message', e.target.value)} className="w-full border p-2 rounded h-32" required />
        <button type="submit" className="bg-blue-600 text-white px-4 py-2 rounded">Send Message</button>
      </form>
    </div>
  );
}

What does this JSX render?

The form has four fields: name (required), email (required), phone (optional), and message (required). The required attribute on inputs prevents submission with empty values. The success and error messages render conditionally based on the status state.

  • Save app/contact/page.tsx.
  • Open http://localhost:3000/contact in your browser.

You should see a Contact Us heading with four form fields (Name, Email, Phone, Your message) and a blue Send Message button.

Page not loading?

Make sure your Next.js dev server is still running in a separate terminal (npm run dev inside agence-amour). If you see a 404, confirm the file is at app/contact/page.tsx (not app/contacts/page.tsx).

Help me debug my Next.js contact page not loading

Build the dashboard contacts panel

Staff need to see incoming messages from the dashboard. This page fetches from your protected /api/contacts route and lets staff mark messages as read by clicking on them.

  • In your agence-amour project, create app/dashboard/contacts/page.tsx with the following code:
'use client';

import { useEffect, useState } from 'react';

// Shape of a contact message from the API
interface Contact {
  _id: string;
  name: string;
  email: string;
  message: string;
  status: string;
  createdAt: string;
}

export default function DashboardContactsPage() {
  const [contacts, setContacts] = useState<Contact[]>([]);

  // Fetch all contact messages on mount
  useEffect(() => {
    const token = localStorage.getItem('token');
    fetch('http://localhost:4000/api/contacts', {
      headers: { Authorization: `Bearer ${token}` },
    })
      .then((res) => res.json())
      .then(setContacts);
  }, []);

What does this code do?

The component fetches all contact messages from the protected /api/contacts endpoint using the JWT token stored in localStorage. The useEffect runs once on mount, populating the contacts state with the response.

  • Still in app/dashboard/contacts/page.tsx, add the markAsRead handler and the JSX return below the useEffect:
  // Mark a message as read when clicked
  const markAsRead = async (id: string) => {
    const token = localStorage.getItem('token');
    await fetch(`http://localhost:4000/api/contacts/${id}/read`, {
      method: 'PUT',
      headers: { Authorization: `Bearer ${token}` },
    });
    // Update local state so the badge changes immediately
    setContacts(contacts.map((c) => (c._id === id ? { ...c, status: 'read' } : c)));
  };

  return (
    <div className="p-8">
      <h1 className="text-2xl font-bold mb-4">Contact Messages</h1>
      {contacts.length === 0 && <p>No messages yet.</p>}
      {contacts.map((c) => (
        <div key={c._id} onClick={() => markAsRead(c._id)} className="border p-4 mb-2 rounded cursor-pointer hover:bg-gray-50">
          <div className="flex justify-between">
            <span className="font-bold">{c.name}</span>
            <span className={c.status === 'unread' ? 'text-orange-600 font-bold' : 'text-green-600'}>{c.status}</span>
          </div>
          <p className="text-sm text-gray-500">{c.email} | {new Date(c.createdAt).toLocaleDateString()}</p>
        </div>
      ))}
    </div>
  );
}

What does the markAsRead function do?

When staff click a message card, markAsRead sends a PUT request to /api/contacts/:id/read. It then updates the local state so the status badge switches from orange "unread" to green "read" without needing a page refresh.

  • Save app/dashboard/contacts/page.tsx.

Now it is time for the full end-to-end test. Before you run through it, predict what you expect to happen when you submit the contact form as a visitor.

  • Open http://localhost:3000/contact in an incognito window (no login).
  • Fill in the Name field with Test Visitor.
  • Fill in the Email field with visitor@example.com.
  • Type Hello, I'd like to book a consultation. in the message textarea.
  • Click Send Message.

You should see the green "Message sent successfully!" text appear and the form fields reset to empty. In your API terminal, you should see a [Mailer Stub] log line confirming the stub was called.

  • Now log in to the dashboard in your regular browser window.
  • Navigate to http://localhost:3000/dashboard/contacts.

You should see the message from "Test Visitor" listed with an orange "unread" status badge.

  • Click the message card.

The badge should change from orange "unread" to green "read" immediately.

Message not showing in the dashboard?

If the contacts panel shows "No messages yet" after you submitted the form, check two things. First, confirm your API terminal shows a successful POST to /api/public/contact (no errors). Second, make sure your logged-in user has clearance level 2 or higher. The requireClearance(2) middleware blocks lower-level accounts.

Help me debug why contacts are not showing in my dashboard panel

✔️ Awesome, I've got everything!

Great. Double check you have saved all files and that both your API and Next.js servers are running without errors.

ⓧ I'd like to double check the full code

Scroll up and confirm you have these chunks in order within each file:

  • services/mailer.ts: exports sendConfirmationEmail stub with a console.log.
  • services/whatsapp.ts: exports sendWhatsAppNotification placeholder with console.log.
  • routes/contacts.ts: imports authenticateJWT and requireClearance, applies both as middleware, has GET / and PUT /:id/read routes.
  • app/contact/page.tsx: Client Component with form state, handleSubmit POSTing to http://localhost:4000/api/public/contact, and JSX rendering the form with success/error feedback.
  • app/dashboard/contacts/page.tsx: Client Component fetching from http://localhost:4000/api/contacts with auth token, markAsRead handler, and a list of message cards with status badges.

Visitors can now reach out through the contact form without any login, and staff can manage those messages from the dashboard. Next up, you will turn that mailer stub into a real email confirmation system using Nodemailer and Ethereal.

Add Email Confirmation with Nodemailer

Your contact form saves messages to MongoDB, but visitors have no way to know their message was received. A silent form submission feels unreliable.

In this step, you will build a mailer service that automatically sends a confirmation email every time someone submits the contact form. You will use Nodemailer with Ethereal (a free test SMTP service) so you can see the email without configuring a real mail provider.

In this step, get ready to:
  • Create a mailer service that auto-generates Ethereal test credentials and sends confirmation emails.
  • Confirm the existing wiring in your public route that triggers the email on form submission.
  • Add a WhatsApp notification placeholder for future production use.
Create the mailer service with Ethereal auto-setup

Ethereal is a fake SMTP service built into Nodemailer. It catches every email you send and gives you a preview URL to view it in a browser. No account signup needed.

  • Inside your agence-amour-api project, create a new folder called services if it does not already exist.
  • Create a new file called services/mailer.ts and paste the following code:
import nodemailer from 'nodemailer';

let transporter: nodemailer.Transporter | null = null;

async function getTransporter() {
  if (transporter) return transporter;

  // Auto-generate Ethereal test credentials
  const testAccount = await nodemailer.createTestAccount();
  console.log('Ethereal test account created:', testAccount.user);

  transporter = nodemailer.createTransport({
    host: testAccount.smtp.host,
    port: testAccount.smtp.port,
    secure: testAccount.smtp.secure,
    auth: {
      user: testAccount.user,
      pass: testAccount.pass,
    },
  });

  return transporter;
}

What does this code do?

The getTransporter() function creates an Ethereal test account the first time it runs by calling nodemailer.createTestAccount(). This contacts the Ethereal API and returns temporary SMTP credentials (host, port, user, pass).

The transporter is then cached in a module-level variable. On subsequent calls, getTransporter() returns the cached transporter immediately without creating a new account.

  • Below the getTransporter function in the same file, add the sendConfirmationEmail function:
export async function sendConfirmationEmail(to: string, name: string): Promise<void> {
  const transport = await getTransporter();

  const info = await transport.sendMail({
    from: '"Agence Amour" <contact@agence-amour.com>',
    to,
    subject: 'We received your message!',
    text: `Hello ${name},\n\nThank you for reaching out to Agence Amour. We have received your message and will get back to you shortly.\n\nBest regards,\nThe Agence Amour Team`,
    html: `<h2>Hello ${name},</h2><p>Thank you for reaching out to <strong>Agence Amour</strong>. We have received your message and will get back to you shortly.</p><p>Best regards,<br/>The Agence Amour Team</p>`,
  });

  console.log('Confirmation email sent:', info.messageId);
  console.log('Preview URL:', nodemailer.getTestMessageUrl(info));
}

What does this code do?

The sendConfirmationEmail function sends an email with both plain text and HTML versions. It uses the visitor's name for a personalised greeting.

After sending, nodemailer.getTestMessageUrl(info) returns a URL where you can preview the captured email in your browser. This URL is printed to the terminal so you can click it to verify the email content.

  • Save services/mailer.ts.

Why Ethereal instead of Gmail?

Ethereal requires zero configuration and no external account. It generates temporary SMTP credentials automatically, so you never risk sending test emails to real people.

When you deploy to a VPS in a later project, you can swap in real SMTP credentials (Gmail, SendGrid, etc.) without changing the code structure.

✔️ Awesome, I've got everything!

Great. Make sure you have saved services/mailer.ts before moving on.

ⓧ I'd like to double check the full code

Your file should contain these two sections in order:

  • The imports, transporter variable, and getTransporter() function (first code block above).
  • The exported sendConfirmationEmail() function (second code block above).

Scroll up and confirm both blocks are present in your file, in order.

Confirm the mailer is wired into the contact route

Your routes/public.ts already imports and calls sendConfirmationEmail from the previous step. Now that the service file exists, the import resolves correctly.

  • Open routes/public.ts and find this section inside the POST /contact handler:
  try {
    await sendConfirmationEmail(email, name);
  } catch (err) {
    console.error('Failed to send confirmation email:', err);
  }

How does the wiring work?

The route calls sendConfirmationEmail(email, name) after saving the Contact document to MongoDB. It wraps the call in a try/catch so that if the email fails, the contact message is still saved and a success response is returned.

This means visitors always get a "message received" response. The email is a bonus confirmation, not a gate.

Add a WhatsApp notification placeholder

The public route also calls sendWhatsAppNotification when a phone number is provided. You need to create this service so the import resolves.

  • Create a new file called services/whatsapp.ts and paste this code:
// WhatsApp notification placeholder
// Replace with WhatsApp Business API integration during VPS deployment
export async function sendWhatsAppNotification(phone: string, message: string): Promise<void> {
  console.log(`[WhatsApp Placeholder] Notification would be sent to: ${phone}`);
  console.log(`[WhatsApp Placeholder] Message: ${message}`);
  // TODO: Integrate WhatsApp Business API here
  // Example providers: Twilio WhatsApp API, Meta WhatsApp Business Platform
}

What does this code do?

This is a placeholder that logs what a real WhatsApp notification would send. When you deploy to a VPS later, you can replace the console logs with a real WhatsApp Business API integration.

  • Save services/whatsapp.ts.

✔️ Awesome, I've got everything!

Great. Make sure you have saved services/whatsapp.ts before moving on.

ⓧ I'd like to double check the full code

// WhatsApp notification placeholder
// Replace with WhatsApp Business API integration during VPS deployment
export async function sendWhatsAppNotification(phone: string, message: string): Promise<void> {
  console.log(`[WhatsApp Placeholder] Notification would be sent to: ${phone}`);
  console.log(`[WhatsApp Placeholder] Message: ${message}`);
  // TODO: Integrate WhatsApp Business API here
  // Example providers: Twilio WhatsApp API, Meta WhatsApp Business Platform
}
  • Restart your backend server by stopping it with Ctrl+C and running:
npm run dev

The server should start without TypeScript errors, confirming both service files resolve correctly.

Seeing a TypeScript error about missing modules?

Check that services/mailer.ts and services/whatsapp.ts are in the services/ folder at the same level as your routes/ folder. The import in routes/public.ts uses '../services/mailer' and '../services/whatsapp', so the folder structure must match.

Also confirm you installed @types/nodemailer in Step 1. Without it, TypeScript cannot resolve the nodemailer import. Help me fix my TypeScript module error in services/mailer.ts

Now test the full email flow. Before you submit the contact form, what do you think will appear in your terminal output?

  • Open http://localhost:3000/contact in an incognito window (no login needed).
  • Fill in the form with a test name, email, and message.
  • Click the submit button.

Switch back to your terminal running the API server. You should see output including an Ethereal test account username and a Preview URL that looks like https://ethereal.email/message/....

  • Copy the Preview URL from the terminal and open it in your browser.

You should see a confirmation email with the subject "We received your message!" and a personalised greeting using the name you entered in the form.

No Preview URL in the terminal?

If you see "Failed to send confirmation email" with a timeout error, the Ethereal API (api.nodemailer.com) may be temporarily unreachable. Check your internet connection and try restarting the server.

If the form submission returns success but you see no email logs at all, verify that routes/public.ts imports sendConfirmationEmail from '../services/mailer'. Help me debug why my confirmation email is not being sent

Your contact form now saves the message and sends a confirmation email automatically. Next up, you will tackle the Secret Mission and give your blog individual article pages with slug-based routing.

Secret mission

Article Detail Page with Slug Routing

Your blog shows article cards, but clicking "Read more" leads nowhere. A real blog needs individual article pages where visitors can read the full post. Build a dynamic route page that loads each article by its URL-friendly slug.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely locally, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Running dev servers (Next.js on port 3000, Express on port 4000).
  • MongoDB agence-amour database (articles and contacts collections).
  • Uploaded thumbnail files in agence-amour-api/uploads/.
  • Project folders on your Desktop (agence-amour and agence-amour-api).

Keep everything running

No action needed. Choose this if you are still actively building or plan to continue to the next project in the series.

  • Your Next.js and Express dev servers can stay running.
  • MongoDB continues to store your articles and contacts data locally.
  • Uploaded thumbnails remain in agence-amour-api/uploads/.

Pause - I'll come back to this later

Shut down running processes to free up memory. Your files and database stay intact on disk.

  • In each terminal running a dev server, press Ctrl+C to stop the process.
  • Stop MongoDB by running:
brew services stop mongodb-community
  • When you return, start MongoDB again with brew services start mongodb-community and relaunch your dev servers.

Delete - I don't want to use this again

Remove all project resources and start fresh. This deletes all work from this project and the previous projects in the series.

  • Stop both dev servers by pressing Ctrl+C in each terminal.
  • Delete the project folders by running:
cd ~/Desktop
rm -rf agence-amour agence-amour-api
  • Drop the MongoDB database by running:
mongosh --eval 'use agence-amour' --eval 'db.dropDatabase()'
  • Stop MongoDB if you no longer need it:
brew services stop mongodb-community

Nice Work!

Nice Work!

Nice work! You just built a full blog and contact system for Agence Amour, complete with public-facing pages, file uploads, and automatic email confirmations.

You've learned how to:

  • Build a full article editor with Multer thumbnail uploads, auto-generated slugs, and draft/published status management.
  • Create public API routes that serve published content to visitors without requiring authentication, alongside your existing protected routes.
  • Wire up an email confirmation system using Nodemailer with Ethereal for zero-config SMTP testing in development.
  • Secret Mission: Built individual article pages with slug-based dynamic routing using Next.js 16 route segments.

Ready to quiz yourself?