Dynamic Routes, URL Parameters and useParams

Most MERN Blog pages are tied to a specific resource โ€” a post identified by an ID in the URL, a user profile identified by a username, a tag page identified by a slug. These are dynamic routes: the URL path contains a variable segment that changes per resource. React Router’s URL parameter syntax (:id, :slug) lets you define one route that handles all variations, and the useParams hook extracts the variable value so you can fetch the right resource from the Express API. In this lesson you will build the MERN Blog’s dynamic post detail route, a slug-based lookup, and the nested route pattern for the dashboard layout.

Defining Dynamic Routes

// In App.jsx โ€” :id is a URL parameter placeholder
<Routes>
  <Route path="/"             element={<HomePage />} />
  <Route path="/posts/:id"    element={<PostDetailPage />} />  {/* :id is dynamic */}
  <Route path="/users/:username" element={<UserProfilePage />} />

  {/* Multiple params in one path */}
  <Route path="/posts/:postId/comments/:commentId" element={<CommentPage />} />

  {/* Catch-all โ€” must be last */}
  <Route path="*" element={<NotFoundPage />} />
</Routes>
Note: URL parameters in React Router are always strings โ€” useParams() returns { id: '64a1f2b3c8e4d5f6a7b8c9d0' } as a string, not an ObjectId. For MongoDB queries via Mongoose this is fine โ€” Mongoose automatically converts a valid 24-character hex string to an ObjectId. For other numeric IDs you would need to parse: parseInt(id, 10).
Tip: When designing your route for posts, use the MongoDB _id in the URL (/posts/:id) rather than a slug (/posts/:slug) if you also want slug-based URLs. Store both on the post model and define both routes: /posts/:id for API-linked navigation (where you have the ID) and /blog/:slug for SEO-friendly sharing URLs. Express can handle both at the API level.
Warning: When defining routes with dynamic parameters alongside specific string paths, React Router v6 correctly handles the matching โ€” /posts/new (specific) and /posts/:id (dynamic) both work correctly because React Router v6 ranks routes by specificity. However, if you have both /posts/new and /posts/:id, always define the more specific route first to make your intent explicit.

useParams โ€” Reading URL Parameters

import { useParams } from 'react-router-dom';
import { useState, useEffect } from 'react';
import axios from 'axios';

function PostDetailPage() {
  const { id } = useParams(); // reads ":id" from the URL
  // URL: /posts/64a1f2b3c8e4d5f6a7b8c9d0 โ†’ id = '64a1f2b3c8e4d5f6a7b8c9d0'

  const [post,    setPost]    = useState(null);
  const [loading, setLoading] = useState(true);
  const [error,   setError]   = useState(null);

  useEffect(() => {
    if (!id) return;

    const controller = new AbortController();

    const fetchPost = async () => {
      try {
        setPost(null);
        setLoading(true);
        setError(null);
        const res = await axios.get(`/api/posts/${id}`, { signal: controller.signal });
        setPost(res.data.data);
      } catch (err) {
        if (!axios.isCancel(err)) {
          setError(
            err.response?.status === 404 ? 'Post not found' : 'Failed to load post'
          );
        }
      } finally {
        setLoading(false);
      }
    };

    fetchPost();
    return () => controller.abort();
  }, [id]); // re-fetch when id changes (user navigates between posts)

  if (loading) return <Spinner />;
  if (error)   return <ErrorMessage message={error} />;
  if (!post)   return null;

  return (
    <article className="post-detail">
      <h1>{post.title}</h1>
      <div className="post-detail__meta">
        <Avatar src={post.author?.avatar} name={post.author?.name || 'Unknown'} size={32} />
        <span>{post.author?.name}</span>
        <time>{new Date(post.createdAt).toLocaleDateString()}</time>
      </div>
      <div className="post-detail__body"
           dangerouslySetInnerHTML={{ __html: post.body }} />
    </article>
  );
}

useSearchParams โ€” URL Query String

import { useSearchParams } from 'react-router-dom';

// Read and update URL query string parameters
// URL: /posts?tag=mern&page=2

function BlogPage() {
  const [searchParams, setSearchParams] = useSearchParams();

  // Read values
  const tag  = searchParams.get('tag')  || '';
  const page = parseInt(searchParams.get('page') || '1', 10);

  // Update โ€” changes URL without reload
  const setTag = (newTag) => {
    setSearchParams(prev => {
      const next = new URLSearchParams(prev);
      if (newTag) next.set('tag', newTag);
      else        next.delete('tag');
      next.set('page', '1'); // reset page when tag changes
      return next;
    });
  };

  // Filter buttons update the URL โ€” users can bookmark filtered views
  return (
    <div>
      <button onClick={() => setTag('mern')}>#mern</button>
      <button onClick={() => setTag('react')}>#react</button>
      <button onClick={() => setTag('')}>All Posts</button>
      <PostList tag={tag} page={page} />
    </div>
  );
}

useLocation โ€” Current URL Information

import { useLocation } from 'react-router-dom';

// Read the full current location object
function AnalyticsTracker() {
  const location = useLocation();

  useEffect(() => {
    // Track page view on every route change
    analytics.pageView(location.pathname);
  }, [location.pathname]);

  return null;
}

// location object:
// {
//   pathname: '/posts/64a1f2b3...',
//   search:   '?tag=mern',
//   hash:     '#comments',
//   state:    { from: '/login' },  // state passed via navigate()
//   key:      'default'
// }

Common Mistakes

Mistake 1 โ€” Not including the param in the useEffect dependency array

โŒ Wrong โ€” effect does not re-run when the ID changes:

const { id } = useParams();
useEffect(() => {
  fetchPost(id); // id from first render only
}, []); // missing id โ†’ stale data when navigating between posts

โœ… Correct:

useEffect(() => { fetchPost(id); }, [id]); // โœ“ re-fetches on id change

Mistake 2 โ€” Trying to parse a MongoDB ObjectId as a number

โŒ Wrong โ€” ObjectId is a 24-char hex string, not a number:

const id = parseInt(useParams().id, 10); // NaN โ€” ObjectId is not a number

โœ… Correct โ€” use the string directly with Mongoose:

const { id } = useParams(); // '64a1f2b3...' as string โ€” Mongoose handles conversion โœ“

Mistake 3 โ€” Not clearing previous post state when navigating between posts

โŒ Wrong โ€” old post visible while new one loads:

useEffect(() => {
  fetchPost(id).then(setPost); // old post shows until new data arrives
}, [id]);

โœ… Correct โ€” clear post immediately when ID changes:

useEffect(() => {
  setPost(null);     // clear immediately โ†’ spinner shows
  setLoading(true);
  fetchPost(id).then(setPost).finally(() => setLoading(false));
}, [id]); // โœ“

Quick Reference

Task Code
Define dynamic route <Route path="/posts/:id" element={<Detail />} />
Read URL param const { id } = useParams()
Read query string const [params, setParams] = useSearchParams()
Get query value params.get('tag')
Set query value setSearchParams({ tag: 'mern', page: '1' })
Current pathname useLocation().pathname
Location state useLocation().state

🧠 Test Yourself

A user visits /posts/64a1f2b3, then clicks a link to /posts/64a1f2b4 (a different post). The PostDetailPage component remains mounted. How do you ensure the new post loads?