Skip to content
Sign inJoin waitlist

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.

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.
  • The Nitip Next.js edition, unzipped into a folder of its own (my-site/nitip-nextjs), with npm install done and npm run dev working (Install and run).
  • Node.js 20.9 or newer, and about an hour.

In my-site (the folder that holds nitip-nextjs):

Terminal window
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-interactive

That 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.

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:

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:

Terminal window
cd nitip-strapi
node scripts/define-content.mjs

A 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 key field (weekly, monthly…) for the plan’s id.

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:

Terminal window
npm install -D tsx
nitip-strapi/tsconfig.json
// 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:

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:

Terminal window
npx tsx scripts/seed.ts

It 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.
Terminal window
npm run develop

Open 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.

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:

nitip-nextjs/.env.local
# 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-string

To make the secret:

Terminal window
node -e "console.log(require('crypto').randomBytes(24).toString('hex'))"

The client. Create nitip-nextjs/src/lib/strapi.ts:

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:

nitip-nextjs/src/lib/content.ts
/*
* 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:

nitip-nextjs/next.config.ts
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:

nitip-nextjs/src/app/blog/[slug]/page.tsx
/* 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;

Create the route Strapi will call, nitip-nextjs/src/app/api/revalidate/route.ts:

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.

Build and start the site — the webhook matters for the pre-rendered production pages:

Terminal window
npm run build
npm start
  1. Open localhost:3000 — the site looks exactly as before, now read from Strapi.
  2. In Strapi’s Content Manager, open Post, pick one, change the title, and click Publish.
  3. Reload the post on the site (or the blog list, or the home page): the new title is there.
  4. 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.

  • Page copy — headings, intros, the About text, button labels — is in src/content/pages/, and the site details (name, contact, social links) in src/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).

There are two things to host now: Strapi and the site.

  1. 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/uploads folder — see Strapi’s deployment and upload provider docs. Move the content with npm run strapi transfer, or run the seed against the new database.
  2. The site deploys as before (Publish), with STRAPI_URL (Strapi’s public address), STRAPI_TOKEN and STRAPI_REVALIDATE_SECRET in the host’s environment variables. If an upload provider serves the images from its own domain, add that domain to images.remotePatterns in next.config.ts.
  3. 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).

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.