Skip to content
Sign inJoin waitlist

Connect Strapi to an Astro template

The Astro edition is a static site: every page is plain HTML, built ahead of time. Strapi — a free, open-source CMS you host yourself — fits it as a separate Node app with an admin for editors and an API that the site reads while it builds. This guide sets up both for the Nitip Astro edition: at the end, editors manage services, projects, posts, jobs, team and testimonials in Strapi, and the site rebuilds from it when they publish.

We followed every step below on a fresh copy of Nitip 1.0.0 with Strapi 5.56 and Astro 7.3, on a local machine — the setup, the import, the static build, and publishing, unpublishing and deleting in Strapi with the site picking it up. Every other Framebrick Astro template works the same way: its collections come from the same kind of loaders in src/content.config.ts, so only the content types and fields differ — its Using a headless CMS page lists them.

my-site/
├── nitip-astro/ the website: this template
└── nitip-strapi/ Strapi: the admin at localhost:1337/admin and the API
  • The site’s collections load from Strapi’s REST API instead of the JSON files, with a read-only token, so only published entries reach it. The schemas stay, so every entry is checked before a page uses it, and the pages and components don’t change.
  • When an editor publishes, unpublishes or deletes an entry, Strapi calls one URL. While you work, that’s the Astro dev server, which reloads the content. On a host, it’s the deploy hook that rebuilds the site.
  • Images are downloaded from Strapi during the build and optimized like the template’s own, so visitors never load anything from the CMS.
  • The Nitip Astro edition, unzipped into a folder of its own (my-site/nitip-astro), with npm install done and npm run dev working (Install and run).
  • Node.js 22.12 or newer, and about an hour.

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

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-astro/src/content/schemas.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 a schema in the Astro project's src/content/schemas.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/schemas.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-astro, 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 Astro site into Strapi, published, once:
* npx tsx scripts/seed.ts
* Reads the JSON in <site>/src/content/collections and uploads the images from <site>/src/assets/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 type { Career, Post, PostBlock, Project, Service, TeamMember, Testimonial } from "../../nitip-astro/src/content/schemas";
/* The Astro project, in the folder next to this one. */
const site = path.resolve("../nitip-astro");
const TYPES = { ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".png": "image/png", ".webp": "image/webp", ".svg": "image/svg+xml" };
/* One JSON array per collection, in the order the site shows them. */
const read = <T>(name: string): T[] => JSON.parse(fs.readFileSync(path.join(site, "src/content/collections", name), "utf8"));
const services = read<Service>("services.json");
const projects = read<Project>("projects.json");
const posts = read<Post>("posts.json");
const careers = read<Career>("careers.json");
const team = read<TeamMember>("team.json");
const testimonials = read<Testimonial>("testimonials.json");
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, "src/assets", 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: the site reads it from now on.

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. Load the site’s collections from Strapi

Section titled “5. Load the site’s collections from Strapi”

Everything in this step happens in nitip-astro.

.env — where Strapi runs, and the token you copied (create the file if you haven’t for the forms):

nitip-astro/.env
# 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

The loaders. Create nitip-astro/src/lib/strapi.ts. Each loader reads one collection with the fields it needs (populate) and maps Strapi’s entries to the template’s schemas:

nitip-astro/src/lib/strapi.ts
/*
* Collections from Strapi, the CMS next to this project. Each loader reads one collection through Strapi's REST API
* when the site builds (and when the dev server refreshes, see src/integrations/cms-refresh.ts). The read-only token
* in .env only sees published entries; they are mapped to the schemas in src/content/schemas.ts, which check every
* entry before a page uses it.
*/
import type { PostBlock } from "../content/schemas";
/** Where Strapi runs: STRAPI_URL in .env. */
export const cms = import.meta.env.STRAPI_URL ?? "http://localhost:1337";
type Doc = Record<string, any>;
type Params = { [key: string]: string | number | boolean | Params };
/* What each collection includes besides its plain fields: images, components, relations and the post body. */
const populate: Record<string, Params> = {
services: { images: true, tools: true, offer: true, benefits: true, plans: { populate: { included: true } } },
projects: {
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 } },
},
posts: {
image: true,
body: {
on: {
"post.heading": true,
"post.paragraph": true,
"post.list": { populate: { items: { populate: { parts: { populate: { subItems: true } } } } } },
},
},
},
careers: { responsibilities: true, requirements: true, benefits: true },
"team-members": { photo: true },
testimonials: { photo: true, logo: true },
};
/* { 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))}`];
});
}
/** A loader for one collection, in the order of its Position field (`position`, as with the JSON files). */
export function strapi(collection: string, map: (doc: Doc) => Doc, key = "slug") {
return async () => {
const docs: Doc[] = [];
// Strapi pages its results; read every page.
for (let page = 1; ; page++) {
const search = query({ sort: "position:asc", populate: populate[collection], pagination: { page, pageSize: 100 } });
const response = await fetch(`${cms}/api/${collection}?${search.join("&")}`, {
headers: { Authorization: `Bearer ${import.meta.env.STRAPI_TOKEN}` },
}).catch(() => undefined);
if (!response) throw new Error(`Couldn't reach Strapi at ${cms}. Is it running?`);
if (!response.ok) throw new Error(`Strapi ${collection}: ${response.status} ${await response.text()}`);
const { data, meta } = (await response.json()) as { data: Doc[]; meta: { pagination: { pageCount: number } } };
docs.push(...data);
if (page >= meta.pagination.pageCount) break;
}
return docs.map((doc, position) => {
const data = map(doc);
return { id: String(data[key]), position, ...data };
});
};
}
/* Uploads are stored on Strapi (/uploads/…) or on a cloud provider (a full URL); the site needs the full address. A
missing file (deleted from the Media Library) stays undefined, so the schema check names the entry. */
const src = (file: Doc | null | undefined) => (file?.url ? new URL(file.url, cms).href : undefined);
const pair = (files: Doc[] | null | undefined) => [src(files?.[0]), src(files?.[1])];
const text = (value: string | null | undefined) => value ?? "";
const optional = (value: string | null | undefined) => value || undefined;
const highlighted = (items: Doc[] | null | undefined) =>
(items ?? []).map((item) => ({ highlight: optional(item.highlight), text: text(item.text) }));
const lines = (items: Doc[] | null | undefined) => (items ?? []).map((item) => text(item.text));
/* A post body block → the blocks the template renders. A list item's parts are paragraphs, each with the bulleted
sub-list that follows it. */
const paragraph = (item: Doc): PostBlock => ({ type: "paragraph", highlight: optional(item.highlight), text: text(item.text) });
function block(item: Doc): PostBlock {
if (item.__component === "post.heading") return { type: "heading", text: text(item.text), bold: optional(item.bold) };
if (item.__component === "post.paragraph") return paragraph(item);
return {
type: "list",
ordered: item.ordered ?? false,
items: (item.items ?? []).map((row: Doc) =>
(row.parts ?? []).flatMap((part: Doc) => [
paragraph(part),
...(part.subItems?.length ? [{ type: "list" as const, items: part.subItems.map((sub: Doc) => [paragraph(sub)]) }] : []),
]),
),
};
}
export const toService = (doc: Doc) => ({
slug: doc.slug,
number: text(doc.number),
title: doc.title,
images: pair(doc.images),
description: text(doc.description),
tools: (doc.tools ?? []).map((tool: Doc) => ({ label: text(tool.label), href: text(tool.href) })),
offer: highlighted(doc.offer),
benefits: highlighted(doc.benefits),
plans: (doc.plans ?? []).map((plan: Doc) => ({
id: plan.key,
label: text(plan.label),
price: text(plan.price),
note: text(plan.note),
included: lines(plan.included),
})),
});
export const toProject = (doc: Doc) => ({
slug: doc.slug,
title: doc.title,
year: text(doc.year),
image: src(doc.image),
services: (doc.services ?? []).map((service: Doc) => ({ label: service.title, slug: service.slug })),
summary: text(doc.summary),
industry: text(doc.industry),
overview: { text: text(doc.overview?.text), image: src(doc.overview?.image) },
process: { text: text(doc.process?.text), images: pair(doc.process?.images) },
result: { items: highlighted(doc.result?.items), image: src(doc.result?.image) },
});
export const toPost = (doc: Doc) => ({
slug: doc.slug,
title: doc.title,
excerpt: text(doc.excerpt),
image: src(doc.image),
category: text(doc.category),
date: doc.date,
body: (doc.body ?? []).map(block),
});
export const toCareer = (doc: Doc) => ({
slug: doc.slug,
title: doc.title,
summary: text(doc.summary),
responsibilities: lines(doc.responsibilities),
requirements: lines(doc.requirements),
benefits: lines(doc.benefits),
});
export const toTeamMember = (doc: Doc) => ({ name: doc.name, role: text(doc.role), email: text(doc.email), photo: src(doc.photo) });
export const toTestimonial = (doc: Doc) => ({
quote: doc.quote,
name: doc.name,
company: text(doc.company),
service: text(doc.service),
photo: src(doc.photo),
logo: src(doc.logo),
});

The collections. Replace nitip-astro/src/content.config.ts — the schemas stay, only the loaders change:

nitip-astro/src/content.config.ts
/*
* Collections: loaded from Strapi (src/lib/strapi.ts) and validated with the schemas in src/content/schemas.ts.
* Items are keyed by `slug` (team and testimonials by name); `position` keeps the order of Strapi's Position field,
* which getCollection doesn't (src/lib/content.ts sorts by it).
*/
import { defineCollection } from "astro:content";
import { z } from "astro/zod";
import {
careerSchema,
postSchema,
projectSchema,
serviceSchema,
teamMemberSchema,
testimonialSchema,
} from "./content/schemas";
import { strapi, toCareer, toPost, toProject, toService, toTeamMember, toTestimonial } from "./lib/strapi";
const position = { position: z.number().optional() };
export const collections = {
projects: defineCollection({ loader: strapi("projects", toProject), schema: projectSchema.extend(position) }),
services: defineCollection({ loader: strapi("services", toService), schema: serviceSchema.extend(position) }),
posts: defineCollection({ loader: strapi("posts", toPost), schema: postSchema.extend(position) }),
careers: defineCollection({ loader: strapi("careers", toCareer), schema: careerSchema.extend(position) }),
team: defineCollection({ loader: strapi("team-members", toTeamMember, "name"), schema: teamMemberSchema.extend(position) }),
testimonials: defineCollection({
loader: strapi("testimonials", toTestimonial, "name"),
schema: testimonialSchema.extend(position),
}),
};

Instant updates while you work. Create nitip-astro/src/integrations/cms-refresh.ts, which lets Strapi tell the dev server to reload the collections:

nitip-astro/src/integrations/cms-refresh.ts
/*
* Dev only: POST /_refresh reloads the collections from the CMS, so a publish in Strapi shows on the next page load
* without restarting `npm run dev`. Strapi's webhook calls it. A build reads the CMS anyway, so nothing is added there.
*/
import type { AstroIntegration } from "astro";
export default function cmsRefresh(): AstroIntegration {
return {
name: "cms-refresh",
hooks: {
"astro:server:setup": ({ server, refreshContent, logger }) => {
server.middlewares.use("/_refresh", async (req, res) => {
if (req.method !== "POST" || !refreshContent) {
res.statusCode = 405;
res.end();
return;
}
try {
await refreshContent({});
logger.info("Collections reloaded from the CMS.");
res.end("Refreshed");
} catch (error) {
/* Usually an entry the schemas reject; the message names it. */
logger.error(`Couldn't reload the collections: ${error instanceof Error ? error.message : error}`);
res.statusCode = 500;
res.end("Refresh failed");
}
});
},
},
};
}

astro.config.mjs — add the integration, and allow images from Strapi so Astro can download and resize them:

nitip-astro/astro.config.mjs
// @ts-check
import tailwindcss from "@tailwindcss/vite";
import { defineConfig, fontProviders } from "astro/config";
import { loadEnv } from "vite";
import cmsRefresh from "./src/integrations/cms-refresh";
import forms from "./src/integrations/forms";
import siteContent from "./src/content/site.json" with { type: "json" };
const { PUBLIC_FORM_MODE } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
const { PUBLIC_FORM_MODE, STRAPI_URL = "http://localhost:1337" } = loadEnv(process.env.NODE_ENV ?? "production", process.cwd(), "");
export default defineConfig({
// …
/* PUBLIC_FORM_MODE=server adds the form route; see src/lib/forms/README.md. */
integrations: [forms(PUBLIC_FORM_MODE)],
integrations: [forms(PUBLIC_FORM_MODE), cmsRefresh()],
/* Images uploaded in Strapi: Astro downloads, resizes and converts them when it builds. */
image: { domains: [new URL(STRAPI_URL).hostname] },

In Strapi, Settings → Webhooks → Create new webhook:

Field Value
Name Refresh the site
Url http://localhost:4321/_refresh
Events Entry: Publish, Unpublish and Delete

Click Save. While you work, Strapi calls the Astro dev server after every publish; online, this Url becomes your host’s deploy hook (Going live).

With Strapi running, open a second terminal and start the site in nitip-astro:

Terminal window
npm run dev
  1. Open localhost:4321 — 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. The site’s terminal logs Collections reloaded from the CMS; reload the post and the new title is there.
  3. Unpublish the post: it leaves the site. Publish it again, and it returns.
  4. Create a post, publish it, and open /blog/<its slug>. A post saved as a draft stays off the site.

Then build the static site from Strapi:

Terminal window
npm run build
npm run preview

dist/ now holds the whole site, with Strapi’s content and images baked in — the same plain files as before.

  • 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.json. 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 JSON for the import script. The site no longer reads it.
  • The style guide page (/style-guide) shows the first project, service and post from Strapi, so keep at least one of each published — or delete src/pages/style-guide.astro if you don’t need 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 two environment variables: STRAPI_URL (Strapi’s public address) and STRAPI_TOKEN. The build fetches the content and images from it. If an upload provider serves the images from its own domain, add that domain to image.domains in astro.config.mjs.
  3. Publishing: create a deploy hook for the site on its host (a URL that starts a build) and set it as the webhook’s Url in the hosted Strapi. Every publish then rebuilds the site, live a minute or two later. Several publishes in a row start several builds, and a delete calls the webhook twice; hosts queue the builds.

We tested every step above locally, including the static build from Strapi; hosting follows Strapi’s and your host’s own docs, so check them for the details. Keep the token out of Git (.env already is).

Couldn't reach Strapi at http://localhost:1337. Is it running? The site reads Strapi while it starts and builds. Start Strapi first (npm run develop in nitip-strapi), or fix STRAPI_URL in nitip-astro/.env.

Strapi projects: 401 or 403 (or another collection). The site’s token is wrong or missing — check STRAPI_TOKEN in .env, and that the token is Read-only (or allows find on every collection). Restart the dev server after changing .env.

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.

A publish doesn’t show in development. Open the webhook in Strapi and click Trigger: no answer means the dev server isn’t running at the webhook’s Url (the port must be the one npm run dev prints). Restarting the dev server also reloads everything.

Couldn't reload the collections in the dev server’s terminal, or the build stops on an entry. The schemas check every entry, and the message names the collection, the entry and the field — often an image deleted from the Media Library while an entry still uses it. Until it’s fixed, the dev server lists only the entries before it in that collection. Pick another image, publish, and the site reloads.

Remote image … is not allowed by your image configuration. astro.config.mjs is missing the image line from step 5, or STRAPI_URL points to another host than the images.

FailedToFetchRemoteImageDimensions while building. To read each image’s size, Astro fetches it from Strapi and closes the connection after the first bytes, so a build opens hundreds of new connections; on a slow network one can miss Node’s 10-second limit. We saw it with Sanity’s image CDN, and the fix from that guide works here too — in astro.config.mjs, share a few connections with a longer limit (npm install -D undici first):

import { Agent, setGlobalDispatcher } from "undici";
setGlobalDispatcher(new Agent({ connections: 8, connect: { timeout: 60_000 } }));

Another astro dev server is already running. Astro 7 runs one dev server per project: stop the old one with npx astro dev stop, or replace it with npm run dev -- --force.

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.