DEV Community

Nicolas Torres for PlaceKit

Posted on • Originally published at placekit.io

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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ 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
โ”‚   โ””โ”€โ”€ ...
โ””โ”€โ”€ ...
Enter fullscreen mode Exit fullscreen mode

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
โ”‚   โ””โ”€โ”€ ...
โ””โ”€โ”€ ...
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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 (
    /* ... */
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
// ...
Enter fullscreen mode Exit fullscreen mode

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,
        },
      };
}
Enter fullscreen mode Exit fullscreen mode

/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,
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

/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 }));
}
Enter fullscreen mode Exit fullscreen mode

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}>`;
      },
    },
  ],
});

// ...
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
        },
      }),
    });
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
  };
}
Enter fullscreen mode Exit fullscreen mode

/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, ... }
      };
}
Enter fullscreen mode Exit fullscreen mode

/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, ... }
  };
}
Enter fullscreen mode Exit fullscreen mode

/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]
  }));
}
Enter fullscreen mode Exit fullscreen mode

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)