- 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