Connect Strapi to a Next.js template
Strapi is a free, open-source CMS you host yourself: an admin panel for editors and an API for the site, in a Node app of its own with your own database. This guide connects it to the Nitip Next.js edition step by step — at the end, editors manage services, projects, posts, jobs, team and testimonials in Strapi, and the site updates when they publish.
We followed every step below on a fresh copy of Nitip 1.0.0 with Strapi 5.56 and Next.js 16.3, on a local machine —
including the webhook: Strapi calls the site after a publish, and the pages change. Every other Framebrick Next.js
template works the same way: it reads its collections through the same src/lib/content.ts, so only the content types
and fields differ — its Using a headless CMS page lists them.
How it fits together
Section titled “How it fits together”my-site/├── nitip-nextjs/ the website: this template└── nitip-strapi/ Strapi: the admin at localhost:1337/admin and the API- The site reads Strapi’s REST API with a read-only token, so only published entries reach it.
- When an editor publishes, unpublishes or deletes an entry, Strapi calls the site’s
/api/revalidate, and every page is rebuilt from Strapi on its next visit. - Images are uploaded to Strapi and resized by
next/image, like the template’s own.
Before you start
Section titled “Before you start”- The Nitip Next.js edition, unzipped into a folder of its own (
my-site/nitip-nextjs), withnpm installdone andnpm run devworking (Install and run). - Node.js 20.9 or newer, and about an hour.
1. Create Strapi
Section titled “1. Create Strapi”In my-site (the folder that holds nitip-nextjs):
npx create-strapi@latest nitip-strapi --typescript --use-npm --install --skip-cloud --no-example --no-git-init --no-run --dbclient sqlite --dbfile .tmp/data.db --non-interactiveThat makes nitip-strapi with a SQLite database — a single file, nothing to set up; for hosting you’ll use
Postgres or MySQL (Going live). The .env it writes has fresh random secrets.
2. Describe the content
Section titled “2. Describe the content”Strapi keeps each content type as a few files under src/. This script writes them all at once — one type per
collection, mirroring nitip-nextjs/src/content/types.ts — so you don’t have to click them together in the
Content-Type Builder. Create nitip-strapi/scripts/define-content.mjs:
/* * Writes the site's content types and components into src/ — run once: * node scripts/define-content.mjs * Each type mirrors one in the Next.js project's src/content/types.ts. Afterwards the files are yours: change them here * or in the admin's Content-Type Builder. */import fs from "node:fs";import path from "node:path";
const text = { type: "text" };const string = { type: "string" };const required = (attribute) => ({ ...attribute, required: true });const slug = { type: "uid", targetField: "title", required: true };/* The order the site shows entries in: lower first. */const position = { type: "integer", default: 0 };const image = { type: "media", multiple: false, allowedTypes: ["images"], required: true };const images = { type: "media", multiple: true, allowedTypes: ["images"], required: true };const repeat = (component) => ({ type: "component", repeatable: true, component });const single = (component) => ({ type: "component", repeatable: false, component });
/* Components: shared pieces and the post body's blocks. */const components = { "shared.highlighted": { displayName: "Highlighted text", attributes: { highlight: string, text } }, "shared.line": { displayName: "Line", attributes: { text: required(text) } }, "shared.link": { displayName: "Link", attributes: { label: string, href: string } }, "service.plan": { displayName: "Plan", attributes: { key: required(string), label: string, price: string, note: string, included: repeat("shared.line") }, }, "project.overview": { displayName: "Overview", attributes: { text, image } }, "project.process": { displayName: "Process", attributes: { text, images } }, "project.result": { displayName: "Result", attributes: { items: repeat("shared.highlighted"), image } }, /* Post body: what the template renders (src/content/types.ts → PostBlock). A list item is one or more paragraphs, each optionally followed by a bulleted sub-list. */ "post.heading": { displayName: "Heading", attributes: { bold: string, text: string } }, "post.paragraph": { displayName: "Paragraph", attributes: { highlight: string, text } }, "post.sub-item": { displayName: "Sub-list item", attributes: { highlight: string, text } }, "post.list-part": { displayName: "Paragraph in a list item", attributes: { highlight: string, text, subItems: repeat("post.sub-item") } }, "post.list-item": { displayName: "List item", attributes: { parts: repeat("post.list-part") } }, "post.list": { displayName: "List", attributes: { ordered: { type: "boolean", default: false }, items: repeat("post.list-item") } },};
const types = { service: { plural: "services", attributes: { title: required(string), slug, position, number: string, images, description: text, tools: repeat("shared.link"), offer: repeat("shared.highlighted"), benefits: repeat("shared.highlighted"), plans: repeat("service.plan"), }, }, project: { plural: "projects", attributes: { title: required(string), slug, position, year: string, image, services: { type: "relation", relation: "manyToMany", target: "api::service.service" }, summary: text, industry: string, overview: single("project.overview"), process: single("project.process"), result: single("project.result"), }, }, post: { plural: "posts", attributes: { title: required(string), slug, position, excerpt: text, image, category: string, date: { type: "date", required: true }, body: { type: "dynamiczone", components: ["post.heading", "post.paragraph", "post.list"] }, }, }, career: { plural: "careers", attributes: { title: required(string), slug, position, summary: text, responsibilities: repeat("shared.line"), requirements: repeat("shared.line"), benefits: repeat("shared.line"), }, }, "team-member": { plural: "team-members", attributes: { name: required(string), position, role: string, email: { type: "email" }, photo: image }, }, testimonial: { plural: "testimonials", attributes: { quote: required(text), name: required(string), position, company: string, service: string, photo: image, logo: image }, },};
const write = (file, content) => { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, typeof content === "string" ? content : `${JSON.stringify(content, null, 2)}\n`);};const title = (name) => name.replace(/(^|-)(\w)/g, (_, dash, c) => (dash ? " " : "") + c.toUpperCase());
for (const [uid, { displayName, attributes }] of Object.entries(components)) { const [category, name] = uid.split("."); write(`src/components/${category}/${name}.json`, { collectionName: `components_${category}_${name.replaceAll("-", "_")}s`, info: { displayName }, options: {}, attributes, });}
for (const [name, { plural, attributes }] of Object.entries(types)) { const dir = `src/api/${name}`; const api = `api::${name}.${name}`; write(`${dir}/content-types/${name}/schema.json`, { kind: "collectionType", collectionName: plural.replaceAll("-", "_"), info: { singularName: name, pluralName: plural, displayName: title(name) }, options: { draftAndPublish: true }, attributes, }); for (const [kind, factory] of [["controllers", "createCoreController"], ["routes", "createCoreRouter"], ["services", "createCoreService"]]) write(`${dir}/${kind}/${name}.ts`, `import { factories } from "@strapi/strapi";\n\nexport default factories.${factory}("${api}");\n`);}
console.log(`Wrote ${Object.keys(types).length} content types and ${Object.keys(components).length} components.`);Run it in nitip-strapi:
cd nitip-strapinode scripts/define-content.mjsA few choices worth knowing:
- Order. Each type has a Position field; the site lists entries from the lowest number up. New entries start at 0, so give each one its place. (Strapi has no drag-and-drop order of its own.)
- Publishing. Draft & Publish is on: edits stay drafts until Publish, and the site only sees published entries. Unpublishing a service also takes it off the projects that link to it; when you publish it again, publish those projects again too, so they link to it.
- Post bodies are a dynamic zone of Heading, Paragraph and List blocks — the blocks the template renders. A list item is one or more paragraphs, each optionally followed by a bulleted sub-list, which covers every post in the demo.
- Pricing plans have a
keyfield (weekly,monthly…) for the plan’s id.
3. Import the demo content
Section titled “3. Import the demo content”The import script reads the demo content straight from nitip-nextjs, so it runs with tsx. Install it, and keep the
scripts folder out of Strapi’s own TypeScript build — its files import from the folder next door:
npm install -D tsx // Do not include plugins in the server compilation "src/plugins/**", // Scripts run with tsx and read the site's content from the folder next door "scripts/" ]Create nitip-strapi/scripts/seed.ts:
/* * Copies the demo content of the Next.js site into Strapi, published, once: * npx tsx scripts/seed.ts * Reads <site>/src/content/collections and uploads the images from <site>/public/images. Run it with Strapi stopped, * on an empty database; it stops if services already exist. */import fs from "node:fs";import path from "node:path";import { compileStrapi, createStrapi } from "@strapi/strapi";import { careers } from "../../nitip-nextjs/src/content/collections/careers";import { posts } from "../../nitip-nextjs/src/content/collections/posts";import { projects } from "../../nitip-nextjs/src/content/collections/projects";import { services } from "../../nitip-nextjs/src/content/collections/services";import { team } from "../../nitip-nextjs/src/content/collections/team";import { testimonials } from "../../nitip-nextjs/src/content/collections/testimonials";import type { PostBlock } from "../../nitip-nextjs/src/content/types";
/* The Next.js project, in the folder next to this one. */const site = path.resolve("../nitip-nextjs");const TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".svg": "image/svg+xml" };
async function seed() { const strapi = await createStrapi(await compileStrapi()).load(); strapi.log.level = "error"; const documents = (uid: string) => strapi.documents(uid as never);
if ((await documents("api::service.service").count({})) > 0) { console.log("Services already exist — the database is seeded. Delete .tmp/data.db and public/uploads to start over."); return strapi.destroy(); }
/* "/images/blog-1.jpg" → the id of its uploaded file, uploading each file once. */ const uploaded = new Map<string, number>(); async function media(src: string) { if (!uploaded.has(src)) { const filepath = path.join(site, "public", src); const [file] = await strapi.plugin("upload").service("upload").upload({ data: {}, files: { filepath, originalFilename: path.basename(src), mimetype: TYPES[path.extname(src) as keyof typeof TYPES], size: fs.statSync(filepath).size }, }); uploaded.set(src, file.id); } return uploaded.get(src)!; } const lines = (items: string[]) => items.map((text) => ({ text })); const create = (uid: string, data: Record<string, unknown>) => documents(uid).create({ data, status: "published" } as never);
/* A list item's blocks → its parts: each paragraph with the bulleted sub-list that follows it. */ function parts(item: PostBlock[]) { const result: { highlight?: string; text: string; subItems: { highlight?: string; text: string }[] }[] = []; for (const block of item) { if (block.type === "paragraph") result.push({ highlight: block.highlight, text: block.text, subItems: [] }); else if (block.type === "list" && result.length) result[result.length - 1].subItems = block.items.map(([first]) => first.type === "paragraph" ? { highlight: first.highlight, text: first.text } : { text: first.type === "heading" ? first.text : "" }, ); else throw new Error(`A list item holds a ${block.type} the Strapi model doesn't cover.`); } return result; } function body(blocks: PostBlock[]) { return blocks.map((block) => { if (block.type === "heading") return { __component: "post.heading", text: block.text, bold: block.bold }; if (block.type === "paragraph") return { __component: "post.paragraph", highlight: block.highlight, text: block.text }; return { __component: "post.list", ordered: block.ordered ?? false, items: block.items.map((item) => ({ parts: parts(item) })) }; }); }
const serviceIds = new Map<string, string>(); for (const [position, { slug, images, plans, ...service }] of services.entries()) { const doc = await create("api::service.service", { ...service, slug, position, images: [await media(images[0]), await media(images[1])], plans: plans.map(({ id, included, ...plan }) => ({ ...plan, key: id, included: lines(included) })), }); serviceIds.set(slug, doc.documentId); } for (const [position, project] of projects.entries()) await create("api::project.project", { ...project, position, image: await media(project.image), services: project.services.map((ref) => serviceIds.get(ref.slug)!), overview: { text: project.overview.text, image: await media(project.overview.image) }, process: { text: project.process.text, images: [await media(project.process.images[0]), await media(project.process.images[1])] }, result: { items: project.result.items, image: await media(project.result.image) }, }); for (const [position, post] of posts.entries()) await create("api::post.post", { ...post, position, image: await media(post.image), body: body(post.body) }); for (const [position, career] of careers.entries()) await create("api::career.career", { ...career, position, responsibilities: lines(career.responsibilities), requirements: lines(career.requirements), benefits: lines(career.benefits), }); for (const [position, member] of team.entries()) await create("api::team-member.team-member", { ...member, position, photo: await media(member.photo) }); for (const [position, testimonial] of testimonials.entries()) await create("api::testimonial.testimonial", { ...testimonial, position, photo: await media(testimonial.photo), logo: await media(testimonial.logo), });
console.log( `Seeded ${services.length} services, ${projects.length} projects, ${posts.length} posts, ${careers.length} careers, ${team.length} team members, ${testimonials.length} testimonials and ${uploaded.size} images.`, ); await strapi.destroy();}
seed().catch((error) => { console.error(error); process.exit(1);});Run it with Strapi stopped:
npx tsx scripts/seed.tsIt loads Strapi, uploads the images, creates every entry as published and ends with:
Seeded 8 services, 8 projects, 12 posts, 12 careers, 6 team members, 3 testimonials and 80 images.4. Start Strapi
Section titled “4. Start Strapi”npm run developOpen localhost:1337/admin and create the first admin — your account. The demo content is under Content Manager. Leave Strapi running.
Then give the site a key to read with: Settings → API Tokens → Create new API Token.
| Field | Value |
|---|---|
| Name | Nitip site |
| Token duration | Unlimited |
| Token type | Read-only |
Click Save and copy the token — Strapi shows it only once.
5. Read the pages from Strapi
Section titled “5. Read the pages from Strapi”Everything in this step happens in nitip-nextjs.
.env.local — where Strapi runs, the token you copied, and a secret for the webhook (step 6), any long random
string:
# Where Strapi runs. On a host: Strapi's public address.STRAPI_URL=http://localhost:1337# The read-only API token from Strapi (Settings → API Tokens).STRAPI_TOKEN=paste-the-token-here# Any long random string; the webhook sends it (step 6).STRAPI_REVALIDATE_SECRET=replace-with-a-long-random-stringTo make the secret:
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"The client. Create nitip-nextjs/src/lib/strapi.ts:
/* * Reads Strapi's REST API with the read-only token from .env.local. Only published entries come back. */const url = process.env.STRAPI_URL ?? "http://localhost:1337";
type Params = { [key: string]: string | number | boolean | Params };
/* { populate: { image: true } } → populate[image]=true, the way Strapi reads nested parameters. */function query(params: Params, prefix = ""): string[] { return Object.entries(params).flatMap(([key, value]) => { const name = prefix ? `${prefix}[${key}]` : key; return typeof value === "object" ? query(value, name) : [`${encodeURIComponent(name)}=${encodeURIComponent(String(value))}`]; });}
/** Every entry of a collection (Strapi pages its results; this reads all pages). */export async function strapi<T>(collection: string, params: Params): Promise<T[]> { const entries: T[] = []; for (let page = 1; ; page++) { const search = query({ ...params, pagination: { page, pageSize: 100 } }).join("&"); const response = await fetch(`${url}/api/${collection}?${search}`, { headers: { Authorization: `Bearer ${process.env.STRAPI_TOKEN}` }, }); if (!response.ok) throw new Error(`Strapi ${collection}: ${response.status} ${await response.text()}`); const { data, meta } = await response.json(); entries.push(...data); if (page >= meta.pagination.pageCount) return entries; }}
/* Uploads are stored on Strapi (/uploads/…) or on a cloud provider (a full URL); the site needs the full address. */export const media = (file: { url: string } | null | undefined) => (file?.url ? new URL(file.url, url).href : "");The content functions. Replace nitip-nextjs/src/lib/content.ts. The function names and return types stay the
same, so no page or component changes; each one reads a collection with the fields it needs (populate) and maps it
to the template’s types:
/* * Where pages get their collections — now from Strapi (src/lib/strapi.ts). Each function reads one collection, in the * order set by its Position field, and maps the entries to the types in src/content/types.ts, so sections and * design-system components don't change. * * `cache` makes each call run once per request, even when metadata and the page ask for the same entry. */import { cache } from "react";import type { Career, Highlighted, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "@/content/types";import { media, strapi } from "@/lib/strapi";
type File = { url: string };type Text = { highlight?: string | null; text?: string | null };type Line = { text: string };
const highlighted = (items: Text[] = []): Highlighted[] => items.map((item) => ({ highlight: item.highlight ?? undefined, text: item.text ?? "" }));const lines = (items: Line[] = []) => items.map((item) => item.text);const pair = (files: File[] = []): [string, string] => [media(files[0]), media(files[1])];const sorted = { sort: "position:asc" };
export const getServices = cache(async (): Promise<Service[]> => { type Entry = Omit<Service, "images" | "offer" | "benefits" | "plans"> & { images: File[]; offer: Text[]; benefits: Text[]; plans: { key: string; label: string; price: string; note: string; included: Line[] }[]; }; const entries = await strapi<Entry>("services", { ...sorted, populate: { images: true, tools: true, offer: true, benefits: true, plans: { populate: { included: true } } }, }); return entries.map((entry) => ({ slug: entry.slug, number: entry.number ?? "", title: entry.title, images: pair(entry.images), description: entry.description ?? "", tools: entry.tools.map(({ label, href }) => ({ label: label ?? "", href: href ?? "" })), offer: highlighted(entry.offer), benefits: highlighted(entry.benefits), plans: entry.plans.map((plan) => ({ id: plan.key, label: plan.label ?? "", price: plan.price ?? "", note: plan.note ?? "", included: lines(plan.included) })), }));});export const getService = cache(async (slug: string) => (await getServices()).find((item) => item.slug === slug));
export const getProjects = cache(async (): Promise<Project[]> => { type Entry = Omit<Project, "image" | "services" | "overview" | "process" | "result"> & { image: File; services: { title: string; slug: string }[]; overview: { text: string; image: File }; process: { text: string; images: File[] }; result: { items: Text[]; image: File }; }; const entries = await strapi<Entry>("projects", { ...sorted, populate: { image: true, // Relations keep the order they were added in. services: { fields: { 0: "title", 1: "slug" } }, overview: { populate: { image: true } }, process: { populate: { images: true } }, result: { populate: { items: true, image: true } }, }, }); return entries.map((entry) => ({ slug: entry.slug, title: entry.title, year: entry.year ?? "", image: media(entry.image), services: entry.services.map(({ title, slug }) => ({ label: title, slug })), summary: entry.summary ?? "", industry: entry.industry ?? "", overview: { text: entry.overview?.text ?? "", image: media(entry.overview?.image) }, process: { text: entry.process?.text ?? "", images: pair(entry.process?.images) }, result: { items: highlighted(entry.result?.items), image: media(entry.result?.image) }, }));});export const getProject = cache(async (slug: string) => (await getProjects()).find((item) => item.slug === slug));/** The project after this one, back to the first after the last ("Next Project" on a project page). */export const getNextProject = cache(async (slug: string) => { const projects = await getProjects(); return projects[(projects.findIndex((item) => item.slug === slug) + 1) % projects.length];});
/* A post body block from Strapi → the blocks the template renders. A list item's parts are paragraphs, each with the bulleted sub-list that follows it. */type Part = Text & { subItems?: Text[] };type BodyBlock = Text & { __component: string; bold?: string | null; ordered?: boolean; items?: { parts: Part[] }[] };const paragraph = (item: Text): PostBlock => ({ type: "paragraph", highlight: item.highlight ?? undefined, text: item.text ?? "" });function block(item: BodyBlock): PostBlock { if (item.__component === "post.heading") return { type: "heading", text: item.text ?? "", bold: item.bold ?? undefined }; if (item.__component === "post.paragraph") return paragraph(item); return { type: "list", ordered: item.ordered || undefined, items: (item.items ?? []).map(({ parts }) => parts.flatMap((part) => [paragraph(part), ...(part.subItems?.length ? [{ type: "list" as const, items: part.subItems.map((sub) => [paragraph(sub)]) }] : [])]), ), };}
export const getPosts = cache(async (): Promise<Post[]> => { type Entry = Omit<Post, "image" | "body"> & { image: File; body: BodyBlock[] }; const entries = await strapi<Entry>("posts", { ...sorted, populate: { image: true, body: { on: { "post.heading": true, "post.paragraph": true, "post.list": { populate: { items: { populate: { parts: { populate: { subItems: true } } } } } }, }, }, }, }); return entries.map((entry) => ({ slug: entry.slug, title: entry.title, excerpt: entry.excerpt ?? "", image: media(entry.image), category: entry.category ?? "", date: entry.date, body: entry.body.map(block), }));});export const getPost = cache(async (slug: string) => (await getPosts()).find((item) => item.slug === slug));
export const getCareers = cache(async (): Promise<Career[]> => { type Entry = Omit<Career, "responsibilities" | "requirements" | "benefits"> & Record<"responsibilities" | "requirements" | "benefits", Line[]>; const entries = await strapi<Entry>("careers", { ...sorted, populate: { responsibilities: true, requirements: true, benefits: true } }); return entries.map((entry) => ({ slug: entry.slug, title: entry.title, summary: entry.summary ?? "", responsibilities: lines(entry.responsibilities), requirements: lines(entry.requirements), benefits: lines(entry.benefits), }));});export const getCareer = cache(async (slug: string) => (await getCareers()).find((item) => item.slug === slug));
export const getTeam = cache(async (): Promise<TeamMember[]> => { const entries = await strapi<Omit<TeamMember, "photo"> & { photo: File }>("team-members", { ...sorted, populate: { photo: true } }); return entries.map((entry) => ({ name: entry.name, role: entry.role ?? "", email: entry.email ?? "", photo: media(entry.photo) }));});
export const getTestimonials = cache(async (): Promise<Testimonial[]> => { const entries = await strapi<Omit<Testimonial, "photo" | "logo"> & { photo: File; logo: File }>("testimonials", { ...sorted, populate: { photo: true, logo: true }, }); return entries.map((entry) => ({ quote: entry.quote, name: entry.name, company: entry.company ?? "", service: entry.service ?? "", photo: media(entry.photo), logo: media(entry.logo), }));});Images. Replace nitip-nextjs/next.config.ts, so next/image resizes the images Strapi serves:
import type { NextConfig } from "next";
const strapi = new URL(process.env.STRAPI_URL ?? "http://localhost:1337");
const nextConfig: NextConfig = { images: { /* Images uploaded in Strapi; next/image resizes them like the local ones. */ remotePatterns: [{ protocol: strapi.protocol.replace(":", "") as "http" | "https", hostname: strapi.hostname, port: strapi.port }], /* Next.js won't fetch images from a private address; allow it only while Strapi runs on this machine. */ dangerouslyAllowLocalIP: ["localhost", "127.0.0.1"].includes(strapi.hostname), },};
export default nextConfig;The second line matters only while Strapi runs on your machine: Next.js refuses to fetch images from a private
address unless you allow it, and the config allows it only for localhost.
Let new entries render. The pages in src/app/blog/[slug], careers/[slug], projects/[slug] and
services/[slug] only allow the slugs that existed at build time. Allow new ones:
/* Only the posts in the collection exist; any other slug is a 404. */export const dynamicParams = false;/* Entries published in the CMS after the build render on their first visit; unknown slugs are a 404. */export const dynamicParams = true;6. Publish on save
Section titled “6. Publish on save”Create the route Strapi will call, nitip-nextjs/src/app/api/revalidate/route.ts:
import { revalidatePath } from "next/cache";
/* Strapi calls this after an entry is published, unpublished or deleted (a webhook sending "Authorization: Bearer <STRAPI_REVALIDATE_SECRET>"). Every page is then rebuilt from Strapi on its next visit: pages share content — a renamed service shows on project pages too. */export async function POST(request: Request) { const secret = process.env.STRAPI_REVALIDATE_SECRET; if (!secret || request.headers.get("authorization") !== `Bearer ${secret}`) return new Response("Unauthorized", { status: 401 }); revalidatePath("/", "layout"); return Response.json({ revalidated: true });}Then in Strapi, Settings → Webhooks → Create new webhook:
| Field | Value |
|---|---|
| Name | Revalidate the site |
| Url | http://localhost:3000/api/revalidate |
| Headers | Key Authorization, value Bearer <your STRAPI_REVALIDATE_SECRET> |
| Events | Entry: Publish, Unpublish and Delete |
Click Save.
7. Try it
Section titled “7. Try it”Build and start the site — the webhook matters for the pre-rendered production pages:
npm run buildnpm start- Open localhost:3000 — the site looks exactly as before, now read from Strapi.
- In Strapi’s Content Manager, open Post, pick one, change the title, and click Publish.
- Reload the post on the site (or the blog list, or the home page): the new title is there.
- Create a post, publish it, and open
/blog/<its slug>. A post saved as a draft stays off the site.
With npm run dev every reload reads Strapi, so you see published changes without the webhook.
What stays in the code
Section titled “What stays in the code”- Page copy — headings, intros, the About text, button labels — is in
src/content/pages/, and the site details (name, contact, social links) insrc/content/site.ts. They change rarely, so they stay in the repository. Move them to Strapi single types later if editors need them. src/content/collections/keeps the demo data: the import script reads it, and the style guide page shows samples from it. The site’s pages no longer use it.- The contact and newsletter forms work as before (Forms).
Going live
Section titled “Going live”There are two things to host now: Strapi and the site.
- Strapi is a Node app. Host it on Strapi Cloud or your own server, with Postgres or
MySQL instead of SQLite and an upload provider (S3, Cloudinary and others) instead of the local
public/uploadsfolder — see Strapi’s deployment and upload provider docs. Move the content withnpm run strapi transfer, or run the seed against the new database. - The site deploys as before (Publish), with
STRAPI_URL(Strapi’s public address),STRAPI_TOKENandSTRAPI_REVALIDATE_SECRETin the host’s environment variables. If an upload provider serves the images from its own domain, add that domain toimages.remotePatternsinnext.config.ts. - The webhook’s Url becomes
https://yourcompany.com/api/revalidate.
We tested every step above locally; hosting follows Strapi’s and your host’s own docs, so check them for the details.
Keep the token and the secret out of Git (.env.local already is).
Troubleshooting
Section titled “Troubleshooting”unable to open database file (SQLITE_CANTOPEN_ISDIR) when Strapi starts. nitip-strapi/.env has an empty
DATABASE_FILENAME — create-strapi writes it empty when --dbclient sqlite comes without --dbfile. Set it to
.tmp/data.db.
TS6059: File … is not under 'rootDir' when running the seed. tsconfig.json is missing the scripts/ line from
step 3.
Strapi services: 401 or 403. The site’s token is wrong or missing — check STRAPI_TOKEN in .env.local, and that the
token is Read-only (or allows find on every collection). Restart the site after changing .env.local.
Images don’t load, and the terminal says the hostname resolved to private IP. next.config.ts is missing
the dangerouslyAllowLocalIP line, or STRAPI_URL isn’t localhost.
A published change doesn’t show with npm start. Open the webhook in Strapi and click Trigger: a 401 means the
header’s secret differs from STRAPI_REVALIDATE_SECRET; no answer means the site isn’t running at the webhook’s Url.
The seed says the database is already seeded. It only runs on an empty database. To start over, stop Strapi,
delete .tmp/data.db and public/uploads, and run it again.
Still stuck? Contact support with the step and the error message.
Learn more
Section titled “Learn more”- Strapi: models — content types, components and dynamic zones
- Strapi: REST API and populate — what
src/lib/strapi.tsreads - Strapi: webhooks
- Use a headless CMS with a Next.js template — how every template’s content layer works, and the other CMS options