The Create Post form is the most complex form in the MERN Blog โ multiple field types, a tag input that manages an array of strings, a toggle for publish status, and file URL input for a cover image. It also introduces a new challenge: the form must only be accessible to authenticated users, and the POST request to the Express API must include the JWT token in the Authorization header. In this lesson you will build the complete Create Post form, integrating everything from the previous three lessons โ controlled inputs, validation, API error handling, and authenticated requests.
Setting Up the Axios Instance with Auth Headers
// src/services/api.js โ Axios instance with automatic auth header injection
import axios from 'axios';
const api = axios.create({
baseURL: '/api', // uses the Vite proxy in development
});
// Request interceptor โ adds the JWT token to every request automatically
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// Response interceptor โ handle 401 globally
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid โ clear it and redirect to login
localStorage.removeItem('token');
window.location.href = '/login';
}
return Promise.reject(error);
}
);
export default api;
// Usage: replace axios.get('/api/posts') with api.get('/posts')
// The /api prefix is added by baseURL, the auth header is added by the interceptor
headers: { Authorization: Bearer ${token} } on every protected request โ easy to forget and tedious to maintain. The interceptor runs automatically on every request made via the api instance, regardless of which component or service makes the call.src/services/postService.js that exports functions like createPost(data), updatePost(id, data), deletePost(id) โ each using the configured api instance. Components import from the service file rather than calling Axios directly. This centralises API logic and makes it easy to change the API structure without updating every component.TagInput component (covered in Lesson 5) that lets the user type a tag and press Enter to add it, displaying existing tags as removable chips. Do not try to put all tags in a single text input as a comma-separated string โ this creates parsing bugs when tags contain commas or extra spaces.The Create Post Form
// src/pages/CreatePostPage.jsx
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import api from '@/services/api';
import TagInput from '@/components/ui/TagInput';
const INITIAL_STATE = {
title: '',
body: '',
excerpt: '',
coverImage: '',
tags: [],
published: false,
};
function CreatePostPage() {
const navigate = useNavigate();
const [formData, setFormData] = useState(INITIAL_STATE);
const [errors, setErrors] = useState({});
const [loading, setLoading] = useState(false);
const [apiError, setApiError] = useState('');
// Generic handler for text, textarea, checkbox
const handleChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value,
}));
if (errors[name]) setErrors(prev => ({ ...prev, [name]: '' }));
};
// Special handler for the tags array (from TagInput component)
const handleTagsChange = (newTags) => {
setFormData(prev => ({ ...prev, tags: newTags }));
};
const validate = () => {
const errs = {};
if (!formData.title.trim()) errs.title = 'Title is required';
else if (formData.title.length < 3) errs.title = 'Title must be at least 3 characters';
if (!formData.body.trim()) errs.body = 'Body is required';
else if (formData.body.length < 10) errs.body = 'Body must be at least 10 characters';
if (formData.coverImage && !/^https?:\/\/.+/.test(formData.coverImage))
errs.coverImage = 'Cover image must be a valid URL';
return errs;
};
const handleSubmit = async (e) => {
e.preventDefault();
const validationErrors = validate();
if (Object.keys(validationErrors).length) { setErrors(validationErrors); return; }
setLoading(true);
setErrors({});
setApiError('');
try {
const res = await api.post('/posts', formData);
navigate(`/posts/${res.data.data._id}`); // redirect to the new post
} catch (err) {
const resData = err.response?.data;
if (resData?.errors) {
setErrors(Object.fromEntries(resData.errors.map(e => [e.field, e.message])));
} else {
setApiError(resData?.message || 'Failed to create post. Please try again.');
}
} finally {
setLoading(false);
}
};
return (
<div className="create-post-page">
<h1>Write a New Post</h1>
{apiError && <div className="alert alert--error">{apiError}</div>}
<form onSubmit={handleSubmit} noValidate>
{/* Title */}
<div className="form-group">
<label htmlFor="title">Title *</label>
<input
id="title" name="title" type="text"
value={formData.title} onChange={handleChange}
className={errors.title ? 'input input--error' : 'input'}
placeholder="Enter post title..."
disabled={loading}
/>
{errors.title && <p className="field-error">{errors.title}</p>}
</div>
{/* Excerpt */}
<div className="form-group">
<label htmlFor="excerpt">Excerpt (optional)</label>
<input
id="excerpt" name="excerpt" type="text"
value={formData.excerpt} onChange={handleChange}
className="input" placeholder="Brief summary..."
maxLength={500} disabled={loading}
/>
</div>
{/* Cover Image URL */}
<div className="form-group">
<label htmlFor="coverImage">Cover Image URL (optional)</label>
<input
id="coverImage" name="coverImage" type="url"
value={formData.coverImage} onChange={handleChange}
className={errors.coverImage ? 'input input--error' : 'input'}
placeholder="https://..." disabled={loading}
/>
{errors.coverImage && <p className="field-error">{errors.coverImage}</p>}
</div>
{/* Body */}
<div className="form-group">
<label htmlFor="body">Content *</label>
<textarea
id="body" name="body"
value={formData.body} onChange={handleChange}
className={errors.body ? 'input input--error' : 'input'}
rows={12} placeholder="Write your post here..."
disabled={loading}
/>
{errors.body && <p className="field-error">{errors.body}</p>}
</div>
{/* Tags */}
<div className="form-group">
<label>Tags (optional)</label>
<TagInput
tags={formData.tags}
onChange={handleTagsChange}
disabled={loading}
/>
</div>
{/* Publish toggle */}
<div className="form-group form-group--inline">
<input
id="published" name="published" type="checkbox"
checked={formData.published} onChange={handleChange}
disabled={loading}
/>
<label htmlFor="published">Publish immediately</label>
</div>
{/* Action buttons */}
<div className="form-actions">
<button type="button" onClick={() => navigate(-1)} disabled={loading}>
Cancel
</button>
<button type="submit" className="btn btn--primary" disabled={loading}>
{loading ? 'Publishing...' : formData.published ? 'Publish Post' : 'Save Draft'}
</button>
</div>
</form>
</div>
);
}
export default CreatePostPage;
Common Mistakes
Mistake 1 โ Forgetting to include the auth header on protected requests
โ Wrong โ using plain Axios without a token:
await axios.post('/api/posts', formData); // no Authorization header โ 401
โ Correct โ use the configured api instance with the interceptor:
await api.post('/posts', formData); // interceptor adds Bearer token โ
Mistake 2 โ Not redirecting to the new post after creation
โ Wrong โ staying on the create form after success:
const res = await api.post('/posts', formData);
// No navigation โ user must manually go back to see the new post
โ Correct โ redirect to the newly created post:
const res = await api.post('/posts', formData);
navigate(`/posts/${res.data.data._id}`); // โ immediately see the new post
Mistake 3 โ Using a plain text input for tags instead of a dedicated TagInput
โ Wrong โ storing tags as a comma-separated string:
const [tagsString, setTagsString] = useState('');
// "mern, react, beginner" โ splitting on comma is fragile
// Must remember to split before sending to API
โ Correct โ manage tags as an array with a dedicated component (built in Lesson 5).
Quick Reference
| Task | Code |
|---|---|
| Axios with auth | Configure interceptor on api instance; use api.post() |
| Textarea controlled | <textarea value={text} onChange={handleChange}> |
| Checkbox controlled | checked={formData.published} onChange={handleChange} |
| Redirect after create | navigate(`/posts/${res.data.data._id}`) |
| Cancel button | <button onClick={() => navigate(-1)}>Cancel</button> |
| Dynamic submit label | {loading ? 'Saving...' : published ? 'Publish' : 'Save Draft'} |