back to Blog

How to use DEV.to as a CMS to make a blog with NextJS

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:

  1. Preparing NextJS
  2. Preparing methods
  3. Listing articles
  4. Article page
  5. Updating canonical for SEO
  6. 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=
DEVTO_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!