See the live version of Headless CMS Blog.
A React single-page application that pulls all of its content from Prismic β a headless CMS β through its Content Query API. The whole app is built around clean, UX-friendly URLs: articles live under date-based nested routes and can be filtered, sorted and paginated entirely through the URL's query string, so every view is shareable and bookmarkable.
Main features:
- Content fetched from a Prismic headless CMS (no custom back-end needed)
- Nested, date-aware routing β e.g.
/articles/:year/:month/:uid - URL-driven filtering & sorting β state lives in
searchParams, not local state - Category filtering powered by Prismic content relationships
- Reusable client-side pagination delivered as a Higher-Order Component
- Redux Toolkit store with thunks and request caching
- Fully responsive, dark-themed UI built with styled-components + CSS custom properties
Β
Built on React 19 with Redux Toolkit 2, React Router 7 and styled-components 6, using @prismicio/client and @prismicio/react for the CMS layer.
Β
The project uses node and npm. With them installed, type into the terminal:
npm i
npm startThe app talks to a public Prismic repository (react-routing-blogg), so there's no API key or .env to set up β it works out of the box.
Β
- URL as the single source of truth (
useFiltershook) β sorting, pagination size and active tags are read from and written to the URL'ssearchParams, so the current view can be shared or refreshed without losing state:
const [searchParams, setSearchParams] = useSearchParams();
const { year, month } = useParams();
const sorting = searchParams.get('sorting') || 'date-desc';
const pagination = searchParams.get('pagination') || 3;
const tags = searchParams.getAll('tags');
const setTags = (value) => {
setSearchParams((prev) => {
prev.delete('tags');
value.forEach((tag) => prev.append('tags', tag));
return prev;
});
};Β
- Nested routes that double as a date filter β the router exposes year/month segments, and the same
useFiltershook reads them viauseParamsto narrow the article list down to a given period:
<Routes>
<Route path="/articles/:year/:month/:uid" element={<Article />} />
<Route path="/articles/:year/:month" element={<Articles />} />
<Route path="/articles/:year" element={<Articles />} />
<Route path={'/articles'} element={<Articles />} />
<Route path={'/'} element={<Home />} />
<Route path={'*'} element={<Error />} />
</Routes>const nestedRouteFilterData = (data) => {
if (!data) return null;
if (!year && !month) return data;
return data.filter((el) => {
const date = new Date(el.data.timestamp);
const matchYear = year ? date.getFullYear() === Number(year) : true;
const matchMonth = month ? date.getMonth() + 1 === Number(month) : true;
return matchYear && matchMonth;
});
};| Route | View |
|---|---|
/ |
Home |
/articles, /articles/:year, /articles/:year/:month |
Articles (filtered list) |
/articles/:year/:month/:uid |
Article |
* |
Error |
Β
- Pagination as a Higher-Order Component β
withPaginationwraps any list component, injecting the sliced data and the prev/next controls. It reads the page size straight from the URL viauseFilters, so list and pagination stay in sync:
const withPagination =
(WrappedComponent) =>
({ data, ...props }) => {
const { pagination } = useFilters();
const [currPage, setCurrPage] = useState(1);
const totalPages = Math.ceil(data?.length / pagination);
const paginatedData = data?.slice(
(currPage - 1) * pagination,
currPage * pagination
);
return data ? (
<StyledPaginatedArticleList>
<WrappedComponent data={paginatedData} {...props} />
{/* prev / next buttons + page indicator */}
</StyledPaginatedArticleList>
) : (
<Navigate to={'/error'} />
);
};Β
- Redux Toolkit slices with cached thunks β data and categories each live in their own slice. The thunks bail out early when the data is already in the store, so navigating between views doesn't re-fetch. The
postquery also pulls related category names in one request viafetchLinks:
export const fetchPages = () => (dispatch, getState) => {
if (getState().data.data) return;
client
.getAllByType('post', {
fetchLinks: ['category.name'],
})
.then((data) => dispatch(setDataAction(data)))
.catch((err) => dispatch(setErrorAction(err)))
.finally(() => dispatch(setLoadingAction(false)));
};Components never touch Redux directly β they go through small custom hooks that expose a clean { loading, data, error } shape:
const usePages = () => {
const dispatch = useDispatch();
const loading = useSelector((state) => state.data.loading);
const data = useSelector((state) => state.data.data);
const error = useSelector((state) => state.data.error);
useEffect(() => {
dispatch(fetchPages());
}, [dispatch]);
return { loading, data, error };
};Β
- Headless CMS integration β a single Prismic client powers the whole app, and the CMS Rich Text is mapped to project components through a
componentsoptions map, keeping styling fully under the app's control:
import * as prismic from '@prismicio/client';
const client = prismic.createClient('react-routing-blogg');
export default client;const options = {
heading1: ({ children }) => <h3 className="card__title">{children}</h3>,
paragraph: ({ children }) => <p className="card__desc">{children}</p>,
};
<PrismicRichText field={article?.data.title} components={options} />
<PrismicImage field={article?.data.image} className="card__image" />Β
A few directions I'd like to take this project further:
The Rich Text already renders through a components map, which is the natural extension point. Adding an embed handler would let YouTube/Vimeo content render inline β right now the body only maps headings, paragraphs and images:
const options = {
heading1: ({ children }) => <h1 className="article__title">{children}</h1>,
paragraph: ({ children }) => <p className="article__desc">{children}</p>,
// embed: ({ node }) => <VideoEmbed oembed={node.oembed} />, // β next step
};Navigation is currently Home + Articles. An About page (and other evergreen content) would round the site out β either as its own Prismic Custom Type or as simple static routes, reusing the existing layout and styling.
Bringing in React Helmet (as hinted in the task brief) would let every article set its own <title> and meta description β worth it for a content site that's meant to be shared and indexed.
Category data is already fetched and related to posts via fetchLinks. Surfacing it as dedicated /category/:name routes would make that relationship even more useful for readers browsing by topic.
Β
Write sth nice ;) Find me on GitHub as TetraMeister.
Β
Thanks to my Mentor β Mateusz Bogolubow (devmentor.pl) β for providing me with this task and for the code review.

