Strapi + Nextjs
최근 개인 프로젝트를 진행하면서 빠르게 CMS와 사이트를 구축해야 할 일이 생겼다. 목표는 포트폴리오 사이트지만 클라이언트가 CMS에 데이터를 업로드하면 사이트에 반영이 되어야했다. 노코드로 개발할까 했지만 디자인이나 기능이 노코드로 구현하기에는 은근히 복잡한 부분이 있었다. 하루 이틀 안에 완성된 프로토타입을 배포해야 하는 상황에서 기존의 방식으로는 개발 시간이 너무 길었다.
그러다 발견한 것이 Strapi였다. Strapi는 오픈소스 기반의 헤드리스 CMS로, CMS와 백엔드를 한 번에 해결할 수 있는 솔루션이다. 특히, 커스터마이징이 쉽고, Next.js 같은 프론트엔드 프레임워크와 잘 맞는 구조를 제공한다는 점에서 매력적이었다.
Strapi 소개
Strapi
Strapi는 Node.js 기반의 오픈소스 헤드리스 CMS로, 데이터를 관리하는 백엔드와 API를 동시에 제공하는 강력한 솔루션이다.
Strapi 스키마 생성 및 수정
1.
Admin Panel의 Content Type Builder로 이동한다.
2.
새로운 Content Type을 생성하고 필요한 필드를 추가한다. 예를 들어, 블로그 게시물이라면 title, content, author 같은 필드를 추가할 수 있다.
3.
저장하고 API를 생성하면 데이터 관리가 가능하다.
Strapi API 커스터마이징
Strapi의 기본 API는 Content Type 생성 시 자동으로 만들어지지만, 경우에 따라 커스터마이징이 필요하다. 예를 들어, 특정 데이터를 필터링하거나, 복잡한 로직을 추가해야 할 때이다.
Custom Route 추가하기
src/api/<content-type>/routes/custom-routes.js 파일을 생성한다. 주의할 점은 01-custom-routes.js 처럼 넘버링(ex. 01-, 02-, …)을 꼭 prefix로 붙여줘야 strapi 프레임워크에서 커스텀 라우트를 인식한다는 것이다.
export default {
routes: [
{
method: "GET",
path: "/articles/:slug",
handler: "article.findOneBySlug",
},
],
};
JavaScript
복사
위 예시는 /articles/:slug 라는 경로를 새로 정의한 것이다.
Custom Controller 작성하기
src/api/<content-type>/controllers/controller.ts 파일을 수정한다. 함수 이름은 커스텀 라우터에서 정의한 핸들러와 이름이 같아야 한다.
import { factories } from "@strapi/strapi";
export default factories.createCoreController(
"api::article.article",
({ strapi }) => ({
async findOneBySlug(ctx) {
const { slug } = ctx.params;
const entity = await strapi.db.query("api::article.article").findOne({
where: { slug },
populate: { images: true },
});
if (!entity) {
console.log("No entity found for slug:", slug);
return ctx.notFound("Article not found");
}
const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
return this.transformResponse(sanitizedEntity);
},
})
);
JavaScript
복사
Strapi 안에 Next.js 실행하기
Strapi 프로젝트 내부에 Next.js를 설치해보자. 프로젝트 루트 디렉토리에서 아래 명령어를 실행한다.
npx create-next-app frontend
Shell
복사
Strapi와 Next.js 연결
그 전에 strapi api에 fetch하는 코드를 쉽게 작성하기 위한 util을 만들어보자. (api helper 등 nextjs + strapi 앱 만드는 방법은 이 글을 참고)
// ./frontend/src/app/utils/api-helpers.ts
export function getStrapiURL(path = "") {
return `${process.env.NEXT_PUBLIC_STRAPI_API_URL || "http://localhost:1337"}${path}`;
}
export function getStrapiMedia(url: string | null) {
if (url == null) {
return "";
}
const isMediaFullUrl = url.match(/https?:\/\/.*/);
if (isMediaFullUrl) {
return isMediaFullUrl[0];
}
return `${getStrapiURL()}${url}`;
}
TypeScript
복사
// ./frontend/src/app/utils/fetch-api.tsx
import qs from "qs";
import { getStrapiURL } from "./api-helpers";
export async function fetchAPI(
path: string,
urlParamsObject = {},
options = {}
) {
try {
// Merge default and user options
const mergedOptions = {
next: { revalidate: 60 },
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);
return await response.json();
} catch (error) {
console.error(error);
throw new Error(
`Please check if your server is running and you set all the required tokens.`
);
}
}
TypeScript
복사
use case
•
export async function getWork(slug: string): Promise<IWork> {
const token = process.env.NEXT_PUBLIC_STRAPI_API_TOKEN;
const path = `/articles/${slug}`;
const urlParamsObject = {
fields: ["slug", "description"],
populate: {
images: { fields: ["url"] },
},
};
const options = { headers: { Authorization: `Bearer ${token}` } };
let responseData;
try {
const response = await fetchAPI(path, urlParamsObject, options);
responseData = response.data;
} catch (error) {
console.error("Failed to fetch data:", error);
responseData = { data: null, meta: null };
}
return responseData;
}
TypeScript
복사
populate
Strapi는 기본적으로 관계 데이터를 자동으로 포함하지 않는다. 이렇게 설계된 이유는 불필요한 데이터를 요청하는 것을 방지하고 성능을 최적화하기 위해서다. 하지만 필요에 따라 특정 관계 데이터를 명시적으로 요청할 수 있으며, 이를 가능하게 해주는 옵션이 populate다.
Component, Media 등의 타입은 fields array에 넣어서 바로 요청하지는 못한다. 예를 들어 defaultSeo가 Component 타입이면 fields에 ["siteName", "siteDescription", “defaultSeo”] 같이 넣지 못한다는 의미다.
그래서 아래처럼 populate 속성을 이용해서 관계 데이터를 요청한다.
const urlParamsObject = {
fields: ["siteName", "siteDescription"],
populate: {
favicon: { fields: ["url"] },
defaultSeo: {
populate: ["shareImage"],
},
},
};
TypeScript
복사
nested populate는 관계 데이터 안의 관계 데이터를 요청할 때 사용한다. 예를 들어 Component 타입인 defaultSeo 안의 Media 타입인 shareImage를 요청하려고 하면 위처럼 populate 속성을 한번 더 써서 요청할 수 있다.
Strapi 배포
strapi cloud + vercel로 배포했다. strapi는 셀프 호스팅도 가능하긴 하지만 strapi cloud도 가격이 싸서 그냥 이걸로 호스팅하기로 결정했다.
strapi 사이트에서 project 생성하고 github repository 연결한 뒤 deployment만 하면 된다.
production으로 deploy시, postgresql 을 사용하고 내부적으로 DB, 백엔드 서버가 알아서 구축된다.
주의할 점1. Strapi SSL 보안 프로토콜 문제
배포에 성공해도 막상 사이트 접속해보면 보안 프로토콜이 설정되어있지 않아 접속이 불가능하다는 오류가 뜨는 경우가 있다.
이런 경우는 HOST 환경변수가 설정되어있지 않았을 때 발생한다.
주의할 점2. Vercel - next.config.js에 이미지 도메인 허용
strapi에서 올린 이미지를 vercel에서 사용하려면 next.config.js 이미지 허용 목록에 미디어 도메인을 추가해줘야한다.
// ./frontend/next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
experimental: {
appDir: true,
},
images: {
remotePatterns: [
{
protocol: "http",
hostname: "localhost",
port: "1337",
pathname: "/uploads/**",
},
{
protocol: "https",
hostname: "images.pexels.com",
},
{
protocol: "https",
hostname: "***.media.strapiapp.com",
},
],
},
};
module.exports = nextConfig;
TypeScript
복사
결론
AWS로 배포를 시도하다 보면 인프라 설정만으로 하루가 다 가버릴 때가 있다. 하지만 Strapi를 사용하면 이런 복잡함에서 벗어나 하루 만에 CMS와 백엔드를 구축하고 배포까지 끝낼 수 있다. 특히, Railway나 Heroku 같은 간단한 배포 플랫폼과 결합하면 정말 말 그대로 "빠르고 간단하게" 프로젝트를 완성할 수 있다.
결론은 간단하다. 복잡한 설정은 제쳐두고, 필요한 걸 빠르게 만들어야 한다면 Strapi만한 도구가 없다.