Building the MERN Blog UI Components

Theory becomes skill through building. In this lesson you will put together everything from this chapter — function components, props, composition, PropTypes, and default values — by building the complete reusable component library for the MERN Blog client. These are the components you will use in every remaining chapter: PostCard, PostList, Spinner, ErrorMessage, Badge, Avatar, and the PageLayout shell. Build them now and they will work immediately when you connect them to real data from the Express API in the chapters ahead.

Component Inventory

Component File Purpose
PageLayout components/layout/PageLayout.jsx Header + main + footer shell for every page
Spinner components/ui/Spinner.jsx Loading indicator
ErrorMessage components/ui/ErrorMessage.jsx Styled error box with optional retry
Badge components/ui/Badge.jsx Small pill label for tags, status, roles
Avatar components/ui/Avatar.jsx Round author image with fallback
PostCard components/posts/PostCard.jsx Blog post summary card for list view
PostList components/posts/PostList.jsx Grid of PostCards with empty and loading states
Note: The components built in this lesson use only static/prop-based data — no API calls, no state, no effects. They are pure presentational components: given the right props, they render the right UI. You will add live data in Chapters 20+ when you connect React to your Express API.
Tip: Build and verify each component in isolation by creating a src/pages/ComponentDemo.jsx that imports all your components and renders them with hardcoded sample data. This visual playground lets you confirm each component looks right before integrating it into the real application flow.
Warning: Keep all visual styles in CSS files, not in component logic. A component’s JSX should set class names based on props but the actual styling belongs in CSS. Mixing layout logic with CSS calculations in JavaScript makes components harder to maintain and override.

Spinner and ErrorMessage

// src/components/ui/Spinner.jsx
function Spinner({ size = 'medium', message = 'Loading...' }) {
  return (
    <div className={`spinner spinner--${size}`} role="status">
      <div className="spinner__circle"></div>
      {message && <p className="spinner__message">{message}</p>}
    </div>
  );
}
export default Spinner;

// src/components/ui/ErrorMessage.jsx
function ErrorMessage({ message, onRetry }) {
  return (
    <div className="error-message" role="alert">
      <span>⚠️</span>
      <p>{message}</p>
      {onRetry && (
        <button className="btn btn--secondary" onClick={onRetry}>Try Again</button>
      )}
    </div>
  );
}
export default ErrorMessage;

Badge and Avatar

// src/components/ui/Badge.jsx
function Badge({ label, variant = 'default' }) {
  return <span className={`badge badge--${variant}`}>{label}</span>;
}
export default Badge;

// src/components/ui/Avatar.jsx
function Avatar({ src, name, size = 40 }) {
  const fallback = `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&size=${size}`;
  return (
    <img
      className="avatar"
      src={src || fallback}
      alt={`${name}'s avatar`}
      width={size}
      height={size}
      style={{ borderRadius: '50%', objectFit: 'cover' }}
      onError={e => { e.target.src = fallback; }}
    />
  );
}
export default Avatar;

PostCard Component

// src/components/posts/PostCard.jsx
import Avatar from '@/components/ui/Avatar';
import Badge  from '@/components/ui/Badge';
import PropTypes from 'prop-types';

function PostCard({ post, onDelete, isOwner = false }) {
  const { _id, title, excerpt, author, tags = [], viewCount = 0, createdAt, featured } = post;

  const formattedDate = new Date(createdAt).toLocaleDateString('en-US', {
    year: 'numeric', month: 'short', day: 'numeric',
  });

  return (
    <article className={`post-card${featured ? ' post-card--featured' : ''}`}>
      {featured && <Badge label="Featured" variant="info" />}

      <h2 className="post-card__title">{title}</h2>
      {excerpt && <p className="post-card__excerpt">{excerpt}</p>}

      <div className="post-card__meta">
        <Avatar src={author?.avatar} name={author?.name || 'Unknown'} size={32} />
        <span>{author?.name || 'Unknown'}</span>
        <time dateTime={createdAt}>{formattedDate}</time>
        <span>👁 {viewCount}</span>
      </div>

      {tags.length > 0 && (
        <div className="post-card__tags">
          {tags.map(tag => <Badge key={tag} label={`#${tag}`} variant="tag" />)}
        </div>
      )}

      {isOwner && (
        <div className="post-card__actions">
          <button className="btn btn--sm btn--danger" onClick={() => onDelete?.(_id)}>
            Delete
          </button>
        </div>
      )}
    </article>
  );
}

PostCard.propTypes = {
  post: PropTypes.shape({
    _id:       PropTypes.string.isRequired,
    title:     PropTypes.string.isRequired,
    excerpt:   PropTypes.string,
    viewCount: PropTypes.number,
    featured:  PropTypes.bool,
    createdAt: PropTypes.string,
    tags:      PropTypes.arrayOf(PropTypes.string),
    author:    PropTypes.shape({ name: PropTypes.string, avatar: PropTypes.string }),
  }).isRequired,
  isOwner:  PropTypes.bool,
  onDelete: PropTypes.func,
};
export default PostCard;

PostList Component

// src/components/posts/PostList.jsx
import PostCard     from './PostCard';
import Spinner      from '@/components/ui/Spinner';
import ErrorMessage from '@/components/ui/ErrorMessage';
import PropTypes    from 'prop-types';

function PostList({ posts = [], loading = false, error = null, onRetry, currentUserId }) {
  if (loading) return <Spinner message="Loading posts..." />;
  if (error)   return <ErrorMessage message={error} onRetry={onRetry} />;

  if (posts.length === 0) {
    return <p className="post-list__empty">No posts found. Be the first to write one!</p>;
  }

  return (
    <div className="post-list">
      {posts.map(post => (
        <PostCard
          key={post._id}
          post={post}
          isOwner={currentUserId === post.author?._id}
        />
      ))}
    </div>
  );
}

PostList.propTypes = {
  posts:         PropTypes.arrayOf(PropTypes.shape({ _id: PropTypes.string.isRequired })),
  loading:       PropTypes.bool,
  error:         PropTypes.string,
  onRetry:       PropTypes.func,
  currentUserId: PropTypes.string,
};
export default PostList;

PageLayout Shell

// src/components/layout/PageLayout.jsx
import PropTypes from 'prop-types';

function PageLayout({ children }) {
  return (
    <div className="page-layout">
      <header className="site-header">
        <a href="/" className="site-header__logo">MERN Blog</a>
        <nav>
          <a href="/">Home</a>
          <a href="/login">Login</a>
          <a href="/register">Register</a>
        </nav>
      </header>
      <main className="page-layout__content">{children}</main>
      <footer className="site-footer">
        <p>© {new Date().getFullYear()} MERN Blog</p>
      </footer>
    </div>
  );
}
PageLayout.propTypes = { children: PropTypes.node.isRequired };
export default PageLayout;

Component Checklist

Check before considering a component done
Capitalised function name, matching file name
Props destructured with sensible defaults
PropTypes defined for all props
Children rendered if the component is a wrapper
Loading, error, and empty states handled
Callback prop defaults prevent “not a function” crashes
Keys on all mapped elements use stable IDs

🧠 Test Yourself

Your PostList renders correctly when passed posts, but shows a blank area when the posts array is empty. What is the simplest fix?