- Published on
Strapi + NextJS Blog ๋ธ๋ก๊ทธ ๐
- Authors
 - Name
- Haneul
 
 

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
