If you need a blog on your website, you can use DEV.to as a CMS to save a lot of time thanks to their API. Your editorial line should be tech-related though, as your articles will be published on both your website and DEV.
As PlaceKit is a tech startup, it is essential to host a blog to increase our search engines presence. Paying for a hosted CMS was not an option as we are self-funded, and self-hosting an open-source CMS would have cost us some setup and maintenance time that we'd rather allocate to more important matters. So building on top of DEV felt like a reasonable option, as we could also leverage its visibility.
We'll go through all the steps to create a blog in NextJS sourcing its articles from DEV API:
- Preparing NextJS
- Preparing methods
- Listing articles
- Article page
- Updating canonical for SEO
- Bonus: handling pagination
๐ See also: DEV API reference.
Don't let the article length or the number of code snippets intimidate you, I had to cover all possible implementation strategies with NextJS, using /pages
or /app
, SSG or SSR... ๐ฅฒ
1. Preparing NextJS
Dependencies
DEV API serves articles in markdown, so we will be using Marked to parse it to HTML, and Prism for code syntax highlighting. Add them to your project:
npm install --save marked prismjs
Environment variables
Let's start with environment variables. We will need these:
# Enable articles search engines indexing
# do NOT set in development, uncomment when in production
# NEXT_PUBLIC_INDEXING=true
# Your website base URL
NEXT_PUBLIC_BASE_URL=http://localhost:3000
# NEXT_PUBLIC_BASE_URL=https://example.com
# DEV settings
DEVTO_API_KEY=<your-api-key>
DEVTO_USERNAME=<your-username>
- Create an API key in your Account settings > Extensions > DEV Community API Keys.
- Replace
<your-api-key>
in the env vars with the generated API key. - Replace
<your-username>
in the env vars with your DEV profile username or organization username.
โ ๏ธ Important note: make sure the build environment for production has all these environment variables set. Sometimes we forget to add them when we're building in a CI environment like Google Cloudbuild for example.
Pages structure with /pages
router
.
โโโ ...
โโโ pages
โ โโโ ...
โ โโโ blog # Blog route
โ โ โโโ index.jsx # Blog home page (articles list)
โ โ โโโ articles # Blog articles
โ โ โโโ [slug].jsx # Blog article dynamic route page
โ โโโ ...
โโโ ...
Pages structure with /app
router
.
โโโ ...
โโโ app
โ โโโ ...
โ โโโ blog # Blog route
โ โ โโโ page.jsx # Blog home page (articles list)
โ โ โโโ articles # Blog articles
โ โ โโโ [slug] # Blog article dynamic route
โ โ โโโ page.jsx # Blog article page
โ โโโ ...
โโโ ...
2. Preparing Methods
We're writing all our DEV API calls as reusable functions that will be common to all NextJS strategies, and prevent some bulk in your component pages.
I personnally like to put them in a utils
folder, but you're free to place them wherever you want to. For the convenience of this tutorial, I'll place those helpers in ./utils/blog-methods.js
and import them using the default absolute path alias @/utils/blog-methods.js
.
// /utils/blog-methods.js
import { marked } from 'marked';
// fetch all articles, looping through the paginated DEV API route
export const getAllArticles = async (fetchOptions) => {
let articles = [];
// URL for user articles
const url = new URL(`https://dev.to/api/articles/${process.env.DEVTO_USERNAME}/all`);
// URL for organization articles
// const url = new URL(`https://dev.to/api/organizations/${process.env.DEVTO_USERNAME}/articles`);
// set default query parameters
url.searchParams.set('page', 1);
url.searchParams.set('per_page', 10);
do {
// fetch current page articles
const res = await fetch(url, {
...fetchOptions,
headers: {
...fetchOptions?.headers,
'api-key': process.env.DEVTO_API_KEY,
},
});
// stop looping on empty response
if (!res.ok) {
throw Error('Failed to fetch articles');
}
const data = await res.json();
if (!data?.length) {
break;
}
// store fetched articles
articles = articles.concat(data);
// increment page query parameter
url.searchParams.set('page', +url.searchParams.get('page') + 1);
} while (true);
// return all articles
return articles;
};
// fetch article by slug
export async function getArticleBySlug(slug, fetchOptions) {
const res = await fetch(`https://dev.to/api/articles/${process.env.DEVTO_USERNAME}/${slug}`, {
...fetchOptions,
headers: {
...fetchOptions?.headers,
'api-key': process.env.DEVTO_API_KEY,
},
});
if (!res.ok) {
return null;
}
const data = await res.json();
// convert markdown to HTML
data.html = marked.parse(data.body_markdown);
return data;
}
3. Listing articles
In our first version, we are focusing on retrieving and showing all articles on the same page. Depending on your NextJS strategy, you will need to call getAllArticles
accordingly:
/pages
router
// /pages/blog/index.jsx
import { getAllArticles } from '@/utils/blog-methods.js';
export default function BlogHome({ articles }) {
// your page template
return (
/* ... */
);
}
// To generate at build time (SSG), use `getStaticProps`
// To render at request time (SSR), use `getServerSideProps` instead
export async function getStaticProps() {
const articles = await getAllArticles();
return {
props: {
articles,
},
};
}
SSR with getServerSideProps
in /pages
router is not recommended because it won't cache the HTTP call, and therefore call DEV API each time a visitor opens the page.
/app
router
// /app/blog/page.jsx
import { getAllArticles } from '@/utils/blog-methods.js';
export default async function Page() {
// fetch data
const articles = await getAllArticles({
cache: 'force-cache', // set 'no-store' to always fetch at request time
next: { revalidate: 24 * 60 * 60 * 1000 }, // revalidate cache every day
});
// your page template
return (
/* ... */
);
}
Link articles
You now have access to the articles
arrays within your page component, with a lot of details to display their title, description, tags, date and some other metadata. See the full JSON response to get the exhaustive list of available properties.
To link an article from the blog home, simply use href={`/blog/articles/${article.slug}`}
:
// /pages/blog/index.jsx OR /app/blog/page.jsx
import Link from 'next/link';
// ...
export default function BlogHome({ articles }) {
// ...
return (
<ul>
{articles?.map((article) => {
return (
<li key={article.id.toString()}>
<Link href={`/blog/articles/${article.slug}`}>{article.title}</Link>
</li>
);
})}
</ul>
);
}
// ...
4. Article page
Again, depending on the NextJS strategy, we'll load the article and handle 404s in different ways. The main difference with the blog home page here is that in SSG/ISG mode, we need to tell NextJS all the pages that it has to generate.
/pages
router, SSR mode
// /pages/blog/article/[slug].jsx
import { getArticleBySlug } from '@/utils/blog-methods.js';
// Article page component
export default function BlogArticle({ article }) {
return <div dangerouslySetInnerHTML={{ __html: article.html }} />;
}
// Fetch article data at request time and inject as page props
export async function getServerSideProps({ params }) {
const article = await getArticleBySlug(params.slug);
return !article
? { notFound: true } // show 404 if fetching article fails
: {
props: {
article,
},
};
}
/pages
router, SSG mode
// /pages/blog/article/[slug].jsx
import { getAllArticles, getArticleBySlug } from '@/utils/blog-methods.js';
// Article page component
export default function BlogArticle({ article }) {
return <div dangerouslySetInnerHTML={{ __html: article.html }} />;
}
// Pregenerate all article pages in SSG
export async function getStaticPaths() {
const articles = await getAllArticles();
const paths = articles.map(({ slug }) => ({ params: { slug } }));
return {
paths,
fallback: false, // show 404 if not on the articles list
};
}
// Fetch article data at build time and inject as page props
export async function getStaticProps({ params }) {
const article = await getArticleBySlug(params.slug);
if (article === null) {
throw Error(`Failed to fetch article ${params.slug}`);
}
return {
props: {
article,
},
};
}
/app
router
// /app/blog/article/[slug]/page.jsx
import { notFound } from 'next/navigation';
import { getArticleBySlug, getAllArticles } from '@/utils/blog-methods.js';
const cacheParams = {
cache: 'force-cache', // set 'no-store' to always fetch at request time
next: { revalidate: 24 * 60 * 60 * 1000 }, // revalidate cache every day
};
// Article page component
export default async function Page({ params }) {
// Fetch article data at request time
const article = await getArticleBySlug(params.slug, { cache: 'no-store' });
if (!article) {
notFound(); // show 404 if fetching article fails
}
return <div dangerouslySetInnerHTML={{ __html: article.html }} />;
}
// (Optional) Pregenerate all article pages at build time
export async function generateStaticParams() {
const articles = await getAllArticles(cacheParams);
return articles.map(({ slug }) => ({ slug }));
}
Adding anchors and code snippets syntax highlighting
Finally, let's update our utils/blog-methods.js
file to configure Prism and Marked to add heading anchors and code syntax highlighting:
// /utils/blog-methods.js
import { marked } from 'marked';
import Prism from 'prismjs';
// load only languages you use
import 'prismjs/components/prism-bash';
import 'prismjs/components/prism-css';
import 'prismjs/components/prism-javascript';
import 'prismjs/components/prism-jsx';
import 'prismjs/components/prism-json';
import 'prismjs/components/prism-markup-templating';
import 'prismjs/components/prism-markdown';
// we handle Prism highlighting manually
Prism.manual = true;
// marked options
marked.use({
breaks: true,
gfm: true,
extensions: [
{
// syntax highlighting on `<code>`
// defaults to `plain` if the language isn't imported
name: 'code',
renderer({ lang, text }) {
const output =
lang in Prism.languages
? Prism.highlight(text.trim(), Prism.languages[lang], lang)
: text.trim();
const syntax = lang in Prism.languages ? lang : 'plain';
return `<pre><code class="language-${syntax}">${output}</code></pre>`;
},
},
{
// adding heading anchors
// same ID syntax as DEV so we don't need to rewrite links href
name: 'heading',
renderer({ text, depth }) {
const id = text
.toLocaleLowerCase()
.replace(/[^\w\s]/g, '')
.trim()
.replace(/\s/g, '-');
return `<h${depth}><a name="${id}" href="#${id}"></a>${text}</h${depth}>`;
},
},
],
});
// ...
And then import the CSS file for your prefered Prism theme in your article page:
// /pages/blog/article/[slug].jsx or /app/blog/article/[slug]/page.jsx
import 'prismjs/themes/prism.css'; // default theme
5. Updating canonical for SEO
Google and other search engine don't like much duplicate content, and will penalize your referencing if you simply post the same article on both DEV and your website.
To avoid this, we make use of the canonical
URL, telling the search engines that the original post is in our website, and that DEV is a legitimate duplicate. So your website will rank articles in priority.
Make sure NEXT_PUBLIC_INDEXING
is not set in development, and set to true
in production, because we will update the DEV article canonical value with the public article URL. We create a new helper updateArticleCanonical
and call it from the getArticleBySlug
helper like so:
// /utils/blog-methods.js
// ...
// fetch article by slug
export async function getArticleBySlug(slug, fetchOptions) {
/* ... */
// update canonical on DEV
await updateArticleCanonical(data);
return data;
}
// update canonical URL when it goes public only if it isn't already set
async function updateArticleCanonical(article) {
if (
process.env.NEXT_PUBLIC_INDEXING &&
!article.canonical_url.startsWith(process.env.NEXT_PUBLIC_BASE_URL)
) {
const canonical = new URL(`/blog/articles/${article.slug}`, process.env.NEXT_PUBLIC_BASE_URL);
await fetch(`https://dev.to/api/articles/${article.id}`, {
method: 'PUT',
body: JSON.stringify({
article: {
canonical_url: canonical.href,
tags: article.tags, // for some reason, if not set, tags will be erased
},
}),
});
}
}
On a side note, we use next-sitemap
to automatically generate a sitemap.xml
, and our blog generated pages are processed without any specific config.
6. Bonus: handling pagination
Handling pagination in the blog home page is a matter of playing with NextJS router optional catch-all feature, like we did for prerendering individual articles at build time.
We could also use simple dynamic routes like /pages/blog/pages/[page].jsx
and redirect /blog
to /blog/page/1
in next.config.js
, but I find it more elegant to use /blog
and /blog/page/2
, and the examples will be more exhaustive that way, for you to be able to choose what you prefer.
So let's add a redirect entry in our next.config.js
for /blog/page/1
to be redirected permanently to /blog
:
const nextConfig = {
// ...
async redirects() {
return [
{
source: '/blog/page/1',
destination: '/blog',
permanent: true,
},
];
},
};
module.exports = nextConfig;
We still then need to load all articles with our getAllArticles
helper from DEV to tell Next how many pages it will have to generate.
Folder structure
Let's first rename /pages/blog/index.jsx
to /pages/blog/[[...page]].jsx
, or /app/blog/page.jsx
to /app/blog/[[...page]]/page.jsx
to be able to catch both /blog
, /blog/page/2
, etc.
Careful that now, with that optional paramater, /blog/invalid/path
will also be caught, so we need to control what paths we allow and show a 404 for the others.
Adding pagination data to articles list
We're wrapping getAllArticles
to add some pagination data that will be common to all Next strategies:
// /utils/blog-methods.js
// ...
export const articlesPerPage = 10;
// fetch all articles with pagination
// careful, page query param is a 1-based integer for URL display
export async function getArticlesAtPage(params, fetchOptions) {
const allArticles = await getAllArticles(fetchOptions);
const pageParam = Number(params.page?.[1] || '1'); // 1-based
const currentPage = Math.max(0, pageParam - 1); // 0-based for computations
const start = currentPage * articlesPerPage;
const end = start + articlesPerPage;
return {
articles: allArticles.slice(start, end),
currentPage: currentPage + 1, // 1-based for display
nbPages: Math.ceil(allArticles.length / articlesPerPage),
perPage: articlesPerPage,
nbArticles: allArticles.length,
};
}
/pages
router, SSR mode
// /pages/blog/index.jsx
import { getArticlesAtPage } from '@/utils/blog-methods.js';
//...
export async function getServerSideProps({ params }) {
const pageProps = await getArticlesAtPage(params);
return !pageProps.articles.length
? { notFound: true }
: {
props: pageProps, // { articles, currentPage, nbPages, ... }
};
}
/pages
router, SSG mode
// /pages/blog/index.jsx
import { getAllArticles, getArticlesAtPage, articlesPerPage } from '@/utils/blog-methods.js';
// ...
export async function getStaticPaths() {
// fetching all articles here to compute the total number of pages
const allArticles = await getAllArticles();
const nbPages = Math.ceil(allArticles.length / articlesPerPage);
const pages = Array.from(Array(nbPages).keys()); // [0,1,...,N-1]
const paths = pages.map((n) => ({
params: {
page:
n === 0
? null // `/blog`
: ['page', `${n + 1}`], // pages [2,3,...,N]
},
}));
return {
paths,
fallback: false, // show 404 if not on the pages list
};
}
export async function getStaticProps({ params }) {
const pageProps = await getArticlesAtPage(params);
return {
props: pageProps, // { articles, currentPage, nbPages, ... }
};
}
/app
router
// /app/blog/page.jsx
import { notFound } from 'next/navigation';
import { getAllArticles, getArticlesAtPage, articlesPerPage } from '@/utils/blog-methods.js';
// ...
const cacheParams = {
cache: 'force-cache', // set 'no-store' to always fetch at request time
next: { revalidate: 24 * 60 * 60 * 1000 }, // revalidate cache every day
};
export default async function Page({ params }) {
const pageProps = await getArticlesAtPage(params, cacheParams);
if (!pageProps.articles?.length) {
notFound();
}
// your page template
return {
/* ... */
};
}
// (Optional) Pregenerate all article pages at build time
export async function generateStaticParams() {
const articles = await getAllArticles(cacheParams);
const nbPages = Math.ceil(articles.length / articlesPerPage);
const pages = Array.from(Array(nbPages).keys()); // [0,1,...,N-1]
return pages.map((n) => ({
page:
n === 0
? null // `/blog`
: ['page', `${n + 1}`], // pages [2,3,...,N]
}));
}
Check out our blog at placekit.io/blog for a live implementation example (and more articles ๐).
We hope this tutorial helped you setting up DEV as a CMS for your own NextJS website!
Top comments (0)