avatar
Published on

Strapi + NextJS Blog ๋ธ”๋กœ๊ทธ ๐Ÿ“”

Authors
  • avatar
    Name
    Haneul
    Twitter

Strapi + NextJS Blog ๋ธ”๋กœ๊ทธ ๐Ÿ“”

์˜ค๋Š˜์€ strapi์™€ nextJS๋กœ ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“œ๋Š” ๋ฐฉ๋ฒ•์„ ์†Œ๊ฐœํ•˜๋ ค๊ณ  ํ•œ๋‹ค.

What is Strapi ?

  • REST API๋ฅผ ์‰ฝ๊ฒŒ ๋งŒ๋“ค ์ˆ˜ ์žˆ๊ฒŒ ๋„์™€์ฃผ๋Š” Open source Node.js Headless CMS
  • ํŠนํžˆ MongoDB์™€์˜ ์—ฐ๊ฒฐ์ด ๊ฐ€๋Šฅํ•˜๊ณ  GraphQL์ด ๊ธฐ๋ณธ์ ์œผ๋กœ ์ œ๊ณต๋˜๊ธฐ์— ํŽธ๋ฆฌํ•˜๊ฒŒ ๋ฐฑ์—”๋“œ ์„œ๋ฒ„๋ฅผ ๊ตฌ์ถ•ํ•  ์ˆ˜ ์žˆ๋‹ค.

What is CMS ?

  • Contents Management System
  • ์ธํ„ฐ๋„ท์ด๋‚˜ ์ปดํ“จํ„ฐ ํ†ต์‹  ๋“ฑ์„ ํ†ตํ•˜์—ฌ ์ œ๊ณต๋˜๋Š” ๊ฐ์ข… ์ •๋ณด๋‚˜ ๊ทธ ๋‚ด์šฉ๋ฌผ๋“ค์„ ๊ด€๋ฆฌํ•˜๋Š” ์ •์˜๋œ ๋ชฉ์ ์„ ๋‹ฌ์„ฑํ•˜๊ธฐ ์œ„ํ•œ ํ†ตํ•ฉ ์š”์†Œ๋“ค์˜ ์ง‘ํ•ฉ์ฒด

Why Strapi ?

  • ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€ ์ œ๊ณต - ์ปจํ…์ธ  ๊ด€๋ฆฌ, ๋ชจ๋ธ๋ง
  • REST API ๋ฐ GraphQL ์‚ฌ์šฉ ๊ฐ€๋Šฅ
  • ๋‹ค์–‘ํ•œ DB์—ฐ๋™ - Mongo, postgresql, mysql ๋“ฑ
  • ์ปค์Šคํ„ฐ๋งˆ์ด์ง• - ์›ํ•˜๋Š” ๋กœ์ง์œผ๋กœ ์ฝ”๋“œ ์ˆ˜์ •
  • CLI ์ œ๊ณต - API ์ƒ์„ฑ ๊ณผ ๊ฐ™์€ ๊ธฐ๋Šฅ์„ CLI๋กœ ์ œ๊ณต

How to create Strapi blog API

// blog-strapi ํด๋” ์ƒ์„ฑ & ์ด๋™
mkdir blog-strapi && cd blog-strapi
// Strapi ๋ธ”๋กœ๊ทธ ํ…œํ”Œ๋ฆฟ ์„ค์น˜
yarn create strapi-app backend  --quickstart --template @strapi/template-blog@1.0.0 blog
// ๋กœ์ปฌ ์„œ๋ฒ„ ์‹คํ–‰
yarn dev

์„œ๋ฒ„๋ฅผ ์‹คํ–‰ํ•˜๋ฉด ์•„๋ž˜ ํŽ˜์ด์ง€๊ฐ€ ๋œจ๊ณ , ๊ฐ€์ž…์„ ํ•ด์ค€๋‹ค.
์ด๋ฏธ ๋ธ”๋กœ๊ทธ ํ…œํ”Œ๋ฆฟ์— ๋งž์ถฐ ์นดํ…Œ๊ณ ๋ฆฌ, ์•„ํ‹ฐํด ํ•„๋“œ๊ฐ€ ์ƒ์„ฑ๋˜์–ด ์žˆ๊ณ , ๋ช‡ ๊ฐœ ๋ฐ์ดํ„ฐ๊ฐ€ ์ฑ„์›Œ์ ธ ์žˆ๋‹ค.

How to create NextJS frontend page

// nextjs ํ”„๋กœ์ ํŠธ ์ƒ์„ฑ
npx create-next-app frontend
// ๊ทธ ์™ธ ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ ์„ค์น˜
yarn add qs moment react-moment react-markdown

fetch data from Strapi

./frontend/lib/api.js ํŒŒ์ผ์„ ์ƒ์„ฑํ•˜๊ณ  ์•„๋ž˜์™€ ๊ฐ™์ด ์ž…๋ ฅํ•ด์ค€๋‹ค.

import qs from "qs";

/**
 * Get full Strapi URL from path
 * @param {string} path Path of the URL
 * @returns {string} Full Strapi URL
 */
export function getStrapiURL(path = "") {
  return `${
    process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337"
  }${path}`;
}

/**
 * Helper to make GET requests to Strapi API endpoints
 * @param {string} path Path of the API route
 * @param {Object} urlParamsObject URL params object, will be stringified
 * @param {Object} options Options passed to fetch
 * @returns Parsed API call response
 */
export async function fetchAPI(path, urlParamsObject = {}, options = {}) {
  // Merge default and user options
  const mergedOptions = {
    headers: {
      "Content-Type": "application/json",
    },
    ...options,
  };

  // Build request URL
  const queryString = qs.stringify(urlParamsObject);
  const requestUrl = `${getStrapiURL(
    `/api${path}${queryString ? `?${queryString}` : ""}`
  )}`;

  // Trigger API call
  const response = await fetch(requestUrl, mergedOptions);

  // Handle response
  if (!response.ok) {
    console.error(response.statusText);
    throw new Error(`An error occured please try again`);
  }
  const data = await response.json();
  return data;
}

fetchAPI ํ•จ์ˆ˜๋Š” ์œ„์˜ getStrapiURL ํ•จ์ˆ˜ ๋•๋ถ„์— ์š”์ฒญ URL์„ ๊ฐ€์ ธ์˜จ๋‹ค.
๊ทธ๋Ÿฐ ๋‹ค์Œ ๋ฌธ์ž์—ดํ™”๋˜๊ณ  ๋ฐ์ดํ„ฐ๋ฅผ JSON ํ˜•์‹์œผ๋กœ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ผ๋ถ€ ๋งค๊ฐœ๋ณ€์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด requestUrl์—์„œ fetch ํ•จ์ˆ˜๋ฅผ ํ˜ธ์ถœํ•œ๋‹ค.

Strapi ๋ฏธ๋””์–ด๋ฅผ ๊ฐ€์ ธ์˜ค๊ธฐ ์œ„ํ•ด ./frontend/lib/media.jsํŒŒ์ผ ์ƒ์„ฑ

import { getStrapiURL } from "./api";

export function getStrapiMedia(media) {
  const { url } = media.data.attributes;
  const imageUrl = url.startsWith("/") ? getStrapiURL(url) : url;
  return imageUrl;
}

์ด ํ•จ์ˆ˜๋Š” ์ด๋ฏธ์ง€๊ฐ€ ํ˜ธ์ŠคํŒ…๋˜๋Š” ์œ„์น˜(๋กœ์ปฌ ์ปดํ“จํ„ฐ ๋˜๋Š” ์„œ๋ฒ„์—์„œ ํ˜ธ์ŠคํŒ…)์— ๋”ฐ๋ผ ์ด๋ฏธ์ง€์˜ ์˜ฌ๋ฐ”๋ฅธ URL์„ ๋ฐ˜ํ™˜ํ•œ๋‹ค.

./frontend/assets/css/style.css ํŒŒ์ผ ์ƒ์„ฑ (CSS)

a {
  text-decoration: none;
}

h1 {
  font-family: Staatliches;
  font-size: 120px;
}

#category {
  font-family: Staatliches;
  font-weight: 500;
}

#title {
  letter-spacing: 0.4px;
  font-size: 22px;
  font-size: 1.375rem;
  line-height: 1.13636;
}

#banner {
  margin: 20px;
  height: 800px;
}

#editor {
  font-size: 16px;
  font-size: 1rem;
  line-height: 1.75;
}

.uk-navbar-container {
  background: #fff !important;
  font-family: Staatliches;
}

img:hover {
  opacity: 1;
  transition: opacity 0.25s cubic-bezier(0.39, 0.575, 0.565, 1);
}

๊ทธ๋ฆฌ๊ณ  next.config.js ํŒŒ์ผ์— ๋‹ค์Œ ์ฝ”๋“œ๋ฅผ ์ถ”๊ฐ€ํ•œ๋‹ค. ์ถ”๊ฐ€ํ•  ์ด๋ฏธ์ง€์˜ ๋„๋ฉ”์ธ ๊ฒฝ๋กœ๋ฅผ ์•„๋ž˜ domains์— ๋„ฃ์–ด์ฃผ๋ฉด ๋œ๋‹ค.

module.exports = {
  reactStrictMode: true,
  images: {
    loader: "default",
    domains: ["localhost"],
  },
};

creating pages

./frontend/pages/_app.js ํŒŒ์ผ ์ƒ์„ฑ
์œ„์—์„œ ์ƒ์„ฑํ•œ fetchAPI ํ•จ์ˆ˜๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ global ๋ฐ์ดํ„ฐ๋ฅผ ๊ฐ€์ ธ์˜จ๋‹ค.

import App from "next/app";
import Head from "next/head";
import "../assets/css/style.css";
import { createContext } from "react";
import { fetchAPI } from "../lib/api";
import { getStrapiMedia } from "../lib/media";

// Store Strapi Global object in context
export const GlobalContext = createContext({});

const MyApp = ({ Component, pageProps }) => {
  const { global } = pageProps;

  return (
    <>
      <Head>
        <link
          rel="shortcut icon"
          href={getStrapiMedia(global.attributes.favicon)}
        />
      </Head>
      <GlobalContext.Provider value={global.attributes}>
        <Component {...pageProps} />
      </GlobalContext.Provider>
    </>
  );
};

// getInitialProps disables automatic static optimization for pages that don't
// have getStaticProps. So article, category and home pages still get SSG.
// Hopefully we can replace this with getStaticProps once this issue is fixed:
// https://github.com/vercel/next.js/discussions/10949
MyApp.getInitialProps = async (ctx) => {
  // Calls page's `getInitialProps` and fills `appProps.pageProps`
  const appProps = await App.getInitialProps(ctx);
  // Fetch global site settings from Strapi
  const globalRes = await fetchAPI("/global", {
    populate: {
      favicon: "*",
      defaultSeo: {
        populate: "*",
      },
    },
  });
  // Pass the data to our page via props
  return { ...appProps, pageProps: { global: globalRes.data } };
};

export default MyApp;

๋‹ค์Œ์€ ./pages/_document.js ์ƒ์„ฑํ•˜์—ฌ ํฐํŠธ๋ฅผ ์ •์˜ํ•จ

import Document, { Html, Head, Main, NextScript } from "next/document";

class MyDocument extends Document {
  render() {
    return (
      <Html>
        <Head>
          {/* eslint-disable-next-line */}
          <link
            rel="stylesheet"
            href="https://fonts.googleapis.com/css?family=Staatliches"
          />
          <link
            rel="stylesheet"
            href="https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/css/uikit.min.css"
          />
          <script
            async
            src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.2.0/js/uikit.min.js"
          />
          <script
            async
            src="https://cdn.jsdelivr.net/npm/uikit@3.10.1/dist/js/uikit-icons.min.js"
          />
          <script
            async
            src="https://cdnjs.cloudflare.com/ajax/libs/uikit/3.2.0/js/uikit.js"
          />
        </Head>
        <body>
          <Main />
          <NextScript />
        </body>
      </Html>
    );
  }
}

export default MyDocument;

๋‹ค์Œ์€ ํŽ˜์ด์ง€ ๋ ˆ์ด์•„์›ƒ์— ํ•„์š”ํ•œ ์ปดํฌ๋„ŒํŠธ ํŒŒ์ผ๋“ค์„ ์ƒ์„ฑํ•œ๋‹ค.
์•„๋ž˜ ์ฝ”๋“œ๋ธ”๋Ÿญ์— ํŒŒ์ผ๋ณ„ ์ฝ”๋“œ๊ฐ€ ์žˆ์Œ

//./frontend/components/nav.js
import React from "react";
import Link from "next/link";

const Nav = ({ categories }) => {
  return (
    <div>
      <nav className="uk-navbar-container" data-uk-navbar>
        <div className="uk-navbar-left">
          <ul className="uk-navbar-nav">
            <li>
              <Link href="/">
                <a>Strapi Blog</a>
              </Link>
            </li>
          </ul>
        </div>
        <div className="uk-navbar-right">
          <ul className="uk-navbar-nav">
            {categories.map((category) => {
              return (
                <li key={category.id}>
                  <Link href={`/category/${category.attributes.slug}`}>
                    <a className="uk-link-reset">{category.attributes.name}</a>
                  </Link>
                </li>
              );
            })}
          </ul>
        </div>
      </nav>
    </div>
  );
};

export default Nav;

//./frontend/components/layout.js

import Nav from "./nav";

const Layout = ({ children, categories, seo }) => (
  <>
    <Nav categories={categories} />
    {children}
  </>
);

export default Layout;

// ./frontend/components/seo.js

import Head from "next/head";
import { useContext } from "react";
import { GlobalContext } from "../pages/_app";
import { getStrapiMedia } from "../lib/media";

const Seo = ({ seo }) => {
  const { defaultSeo, siteName } = useContext(GlobalContext);
  const seoWithDefaults = {
    ...defaultSeo,
    ...seo,
  };
  const fullSeo = {
    ...seoWithDefaults,
    // Add title suffix
    metaTitle: `${seoWithDefaults.metaTitle} | ${siteName}`,
    // Get full image URL
    shareImage: getStrapiMedia(seoWithDefaults.shareImage),
  };

  return (
    <Head>
      {fullSeo.metaTitle && (
        <>
          <title>{fullSeo.metaTitle}</title>
          <meta property="og:title" content={fullSeo.metaTitle} />
          <meta name="twitter:title" content={fullSeo.metaTitle} />
        </>
      )}
      {fullSeo.metaDescription && (
        <>
          <meta name="description" content={fullSeo.metaDescription} />
          <meta property="og:description" content={fullSeo.metaDescription} />
          <meta name="twitter:description" content={fullSeo.metaDescription} />
        </>
      )}
      {fullSeo.shareImage && (
        <>
          <meta property="og:image" content={fullSeo.shareImage} />
          <meta name="twitter:image" content={fullSeo.shareImage} />
          <meta name="image" content={fullSeo.shareImage} />
        </>
      )}
      {fullSeo.article && <meta property="og:type" content="article" />}
      <meta name="twitter:card" content="summary_large_image" />
    </Head>
  );
};

export default Seo;

// ./frontend/components/image.js

import { getStrapiMedia } from "../lib/media";
import NextImage from "next/image";

const Image = ({ image }) => {
  const { alternativeText, width, height } = image.data.attributes;

  return (
    <NextImage
      layout="responsive"
      width={width}
      height={height}
      objectFit="contain"
      src={getStrapiMedia(image)}
      alt={alternativeText || ""}
    />
  );
};

export default Image;

//./frontend/components/articles.js

import React from "react";
import Card from "./card";

const Articles = ({ articles }) => {
  const leftArticlesCount = Math.ceil(articles.length / 5);
  const leftArticles = articles.slice(0, leftArticlesCount);
  const rightArticles = articles.slice(leftArticlesCount, articles.length);

  return (
    <div>
      <div className="uk-child-width-1-2@s" data-uk-grid="true">
        <div>
          {leftArticles.map((article, i) => {
            return (
              <Card
                article={article}
                key={`article__left__${article.attributes.slug}`}
              />
            );
          })}
        </div>
        <div>
          <div className="uk-child-width-1-2@m uk-grid-match" data-uk-grid>
            {rightArticles.map((article, i) => {
              return (
                <Card
                  article={article}
                  key={`article__left__${article.attributes.slug}`}
                />
              );
            })}
          </div>
        </div>
      </div>
    </div>
  );
};

export default Articles;

// ./frontend/components/card.js

import React from "react";
import Link from "next/link";
import NextImage from "./image";

const Card = ({ article }) => {
  return (
    <Link href={`/article/${article.attributes.slug}`}>
      <a className="uk-link-reset">
        <div className="uk-card uk-card-muted">
          <div className="uk-card-media-top">
            <NextImage image={article.attributes.image} />
          </div>
          <div className="uk-card-body">
            <p id="category" className="uk-text-uppercase">
              {article.attributes.category.data.attributes.name}
            </p>
            <p id="title" className="uk-text-large">
              {article.attributes.title}
            </p>
          </div>
        </div>
      </a>
    </Link>
  );
};

export default Card;

// ./frontend/pages/index.js

import React from "react";
import Articles from "../components/articles";
import Layout from "../components/layout";
import Seo from "../components/seo";
import { fetchAPI } from "../lib/api";

const Home = ({ articles, categories, homepage }) => {
  return (
    <Layout categories={categories}>
      <Seo seo={homepage.attributes.seo} />
      <div className="uk-section">
        <div className="uk-container uk-container-large">
          <h1>{homepage.attributes.hero.title}</h1>
          <Articles articles={articles} />
        </div>
      </div>
    </Layout>
  );
};

export async function getStaticProps() {
  // Run API calls in parallel
  const [articlesRes, categoriesRes, homepageRes] = await Promise.all([
    fetchAPI("/articles", { populate: ["image", "category"] }),
    fetchAPI("/categories", { populate: "*" }),
    fetchAPI("/homepage", {
      populate: {
        hero: "*",
        seo: { populate: "*" },
      },
    }),
  ]);

  return {
    props: {
      articles: articlesRes.data,
      categories: categoriesRes.data,
      homepage: homepageRes.data,
    },
    revalidate: 1,
  };
}

export default Home;

// ./frontend/pages/article/[slug].js

import Moment from "react-moment";
import ReactMarkdown from "react-markdown";

import Seo from "../../components/seo";
import Layout from "../../components/layout";

import { fetchAPI } from "../../lib/api";
import { getStrapiMedia } from "../../lib/media";

const Article = ({ article, categories }) => {
  const imageUrl = getStrapiMedia(article.attributes.image);

  const seo = {
    metaTitle: article.attributes.title,
    metaDescription: article.attributes.description,
    shareImage: article.attributes.image,
    article: true,
  };

  return (
    <Layout categories={categories.data}>
      <Seo seo={seo} />
      <div
        id="banner"
        className="uk-height-medium uk-flex uk-flex-center uk-flex-middle uk-background-cover uk-light uk-padding uk-margin"
        data-src={imageUrl}
        data-srcset={imageUrl}
        data-uk-img
      >
        <h1>{article.attributes.title}</h1>
      </div>
      <div className="uk-section">
        <div className="uk-container uk-container-small">
          <ReactMarkdown children={article.attributes.content} />
          <hr className="uk-divider-small" />
          <div className="uk-grid-small uk-flex-left" data-uk-grid="true">
            <div>
              {article.attributes.author.data.attributes.picture && (
                <img
                  src={getStrapiMedia(
                    article.attributes.author.data.attributes.picture
                  )}
                  alt={
                    article.attributes.author.data.attributes.picture.data
                      .attributes.alternativeText
                  }
                  style={{
                    position: "static",
                    borderRadius: "20%",
                    height: 60,
                  }}
                />
              )}
            </div>
            <div className="uk-width-expand">
              <p className="uk-margin-remove-bottom">
                By {article.attributes.author.data.attributes.name}
              </p>
              <p className="uk-text-meta uk-margin-remove-top">
                <Moment format="MMM Do YYYY">
                  {article.attributes.published_at}
                </Moment>
              </p>
            </div>
          </div>
        </div>
      </div>
    </Layout>
  );
};

export async function getStaticPaths() {
  const articlesRes = await fetchAPI("/articles", { fields: ["slug"] });

  return {
    paths: articlesRes.data.map((article) => ({
      params: {
        slug: article.attributes.slug,
      },
    })),
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  const articlesRes = await fetchAPI("/articles", {
    filters: {
      slug: params.slug,
    },
    populate: ["image", "category", "author.picture"],
  });
  const categoriesRes = await fetchAPI("/categories");

  return {
    props: { article: articlesRes.data[0], categories: categoriesRes },
    revalidate: 1,
  };
}

export default Article;

// ./frontend/pages/category/[slug].js

import Seo from "../../components/seo";
import Layout from "../../components/layout";
import Articles from "../../components/articles";

import { fetchAPI } from "../../lib/api";

const Category = ({ category, categories }) => {
  const seo = {
    metaTitle: category.attributes.name,
    metaDescription: `All ${category.attributes.name} articles`,
  };

  return (
    <Layout categories={categories.data}>
      <Seo seo={seo} />
      <div className="uk-section">
        <div className="uk-container uk-container-large">
          <h1>{category.attributes.name}</h1>
          <Articles articles={category.attributes.articles.data} />
        </div>
      </div>
    </Layout>
  );
};

export async function getStaticPaths() {
  const categoriesRes = await fetchAPI("/categories", { fields: ["slug"] });

  return {
    paths: categoriesRes.data.map((category) => ({
      params: {
        slug: category.attributes.slug,
      },
    })),
    fallback: false,
  };
}

export async function getStaticProps({ params }) {
  const matchingCategories = await fetchAPI("/categories", {
    filters: { slug: params.slug },
    populate: {
      articles: {
        populate: "*",
      },
    },
  });
  const allCategories = await fetchAPI("/categories");

  return {
    props: {
      category: matchingCategories.data[0],
      categories: allCategories,
    },
    revalidate: 1,
  };
}

export default Category;

  • SEO ์— ํ•„์š”ํ•œ ๋ฉ”ํƒ€ํƒœ๊ทธ ์ƒ์„ฑ ๋ฐ ๊ธฐ๋ณธ ์ด๋ฏธ์ง€ ์„ค์ •
  • ์•„ํ‹ฐํด ํŽ˜์ด์ง€ ์ƒ์„ฑ
    • getStaticPaths๋กœ ์ง€์ •๋œ ๋ชจ๋“  ๊ฒฝ๋กœ๋ฅผ ์‚ฌ์ „์— ๋ Œ๋”๋ง
    • getStaticProps๋กœ ์•„ํ‹ฐํด ๋‚ ์งœ ๋ฐ ์นดํ…Œ๊ณ ๋ฆฌ๋ฅผ fetchAPI๋กœ ๊ฐ€์ ธ์™€์„œ props ์ •์˜

Strapi ํŠœํ† ๋ฆฌ์–ผ์„ ํ†ตํ•ด NextJS์™€ ํ•จ๊ป˜ ๋ฉ‹์ง„ ๋ธ”๋กœ๊ทธ๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ๋‹ค!
์•„๋ž˜ ์ฐธ๊ณ  ํŽ˜์ด์ง€์—์„œ ๋‹ค์–‘ํ•œ ํŠœํ† ๋ฆฌ์–ผ์„ ๋ณผ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ํ•˜๋‚˜์”ฉ ์ฝ์–ด๋ณด๋Š”๊ฒƒ๋„ Strapi๋ฅผ ์—ฐ์Šตํ•˜๋Š”๋ฐ ์ข‹์„ ๊ฒƒ ๊ฐ™๋‹ค


์ฐธ๊ณ  :
https://strapi.io/,
Strapi tutorial,
https://strapi.io/blog/build-a-blog-with-next-react-js-strapi