Open source maintainers hit a recurring problem: GitHub stats go stale the moment you hardcode them into a portfolio. Star counts climb, contributors join, and your showcase site quietly lies about your work. This tutorial builds a static site that pulls live GitHub data on a schedule, ships almost no JavaScript, and rebuilds itself when content changes, using Strapi 5 for content and Astro 6 for static rendering.
In brief:
- Model projects, tech tags, and contributors in Strapi 5 with explicit relations and the Document Service API.
- Build a GitHub sync service that uses conditional requests with ETags to stay inside the API rate limit.
- Schedule daily syncing through
config/cron-tasks.tswith no external job runner. - Render the whole site as static HTML in Astro 6, hydrating only a single tag filter as an island.
What We're Building
The end product is a static project showcase. Inside Strapi 5 sits the editorial content for each project: a name, tagline, rich description written in the Blocks editor, screenshots from the Media Library, tech stack tags, and a list of contributors. A custom Strapi service reaches out to the GitHub REST API and fills in the live metadata: star count, fork count, language breakdown, open issue count, and the contributor roster. A cron task runs that sync daily so the numbers never drift.
On the frontend, Astro 6 fetches all of this at build time and renders static HTML with near-zero client-side JavaScript. The only interactive piece is a tag filter on the listing page, hydrated as an Astro island with client:load. Everything else stays server-rendered HTML. When you publish a project update in Strapi, a webhook fires at your deploy platform's build hook and triggers a fresh site build.
What you'll learn:
- Modeling projects, tech tags, and contributors with many-to-many and one-to-many relations.
- Building a GitHub sync service with conditional requests for rate limit management.
- Scheduling cron tasks for automatic daily syncing.
- Rendering with Astro's static site generation and
getStaticPaths(). - Triggering rebuilds through Strapi webhooks.
Prerequisites
Before starting, confirm your environment matches these versions. They are pinned for a reason.
- Node.js v22 LTS (latest patch at time of writing: TKTK verify current v22.x patch). This is required, not recommended. Astro 6 dropped Node 18 and 20 entirely; the minimum is Node.js 22.12.0. Strapi 5 supports Active or Maintenance Long-Term Support (LTS) versions, which means v20, v22, and v24. The only version that satisfies both constraints and carries LTS support through 2027 is Node.js v22. Odd-numbered releases like v23 are not supported by Astro. If you run Node 20, Astro 6 will refuse to build.
- Strapi 5.x (this tutorial uses 5.47.x, the latest stable at writing time). Verify the current release at Strapi releases.
- Astro 6.4.x (6.4.7 at writing time).
- A GitHub personal access token. The unauthenticated API limit is 60 requests per hour; an authenticated personal access token raises that to 5,000. You need a token for any practical sync schedule.
- Basic familiarity with TypeScript and REST APIs.
- A code editor and terminal.
Setting Up the Strapi Backend
The backend owns two responsibilities: storing editorial content that humans write, and holding live metadata that machines fetch. Strapi 5 handles both through its Content-Types Builder and a custom service layer. The next five steps install Strapi, model the data, build the sync service, schedule it, and wire up rebuild triggers. Each piece stands on its own, so you can verify the backend works end to end before touching the frontend.
Step 1: Install Strapi 5
Create the project with the official installer.
npx create-strapi@latest showcase-backendThe installer walks you through database selection. SQLite is fine for local development. For production, choose PostgreSQL. Once the install finishes, start the development server:
cd showcase-backend
npm run developStrapi opens the Admin Panel at http://localhost:1337/admin. Create your administrator account before moving on.
Step 2: Define the Project, TechTag, Contributor, and Category Content-Types
You can build these through the Content-Type Builder in the Admin Panel, but defining the schemas directly gives you a clearer picture of the relations. Strapi 5 stores model schemas at ./src/api/[api-name]/content-types/[content-type-name]/schema.json, per the models documentation.
Start with the Category Collection Type. It is the simplest, and the Project relates to it.
// src/api/category/content-types/category/schema.json
{
"kind": "collectionType",
"collectionName": "categories",
"info": {
"singularName": "category",
"pluralName": "categories",
"displayName": "Category"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "name", "required": true },
"description": { "type": "text" },
"sortOrder": { "type": "integer", "default": 0 }
}
}Next, the TechTag Collection Type:
// src/api/tech-tag/content-types/tech-tag/schema.json
{
"kind": "collectionType",
"collectionName": "tech_tags",
"info": {
"singularName": "tech-tag",
"pluralName": "tech-tags",
"displayName": "TechTag"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "name", "required": true },
"icon": { "type": "string" },
"color": { "type": "string" }
}
}The Contributor Collection Type stores GitHub identity fields. The sync service writes to these later.
// src/api/contributor/content-types/contributor/schema.json
{
"kind": "collectionType",
"collectionName": "contributors",
"info": {
"singularName": "contributor",
"pluralName": "contributors",
"displayName": "Contributor"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true },
"githubUsername": { "type": "string", "required": true, "unique": true },
"avatarUrl": { "type": "string" },
"profileUrl": { "type": "string" },
"project": {
"type": "relation",
"relation": "manyToOne",
"target": "api::project.project",
"inversedBy": "contributors"
}
}
}The Project Collection Type ties everything together. It uses a manyToMany relation to TechTag, a oneToMany relation to Contributor, and a manyToOne relation to Category. The relation syntax follows the models reference, which defines relation, target, mappedBy, and inversedBy.
// src/api/project/content-types/project/schema.json
{
"kind": "collectionType",
"collectionName": "projects",
"info": {
"singularName": "project",
"pluralName": "projects",
"displayName": "Project"
},
"options": {
"draftAndPublish": true
},
"attributes": {
"name": { "type": "string", "required": true },
"slug": { "type": "uid", "targetField": "name", "required": true },
"tagline": { "type": "string" },
"description": { "type": "blocks" },
"repositoryUrl": { "type": "string", "required": true },
"liveDemoUrl": { "type": "string" },
"screenshots": {
"type": "media",
"multiple": true,
"allowedTypes": ["images"]
},
"featured": { "type": "boolean", "default": false },
"stars": { "type": "integer", "default": 0 },
"forks": { "type": "integer", "default": 0 },
"openIssues": { "type": "integer", "default": 0 },
"primaryLanguage": { "type": "string" },
"languages": { "type": "json" },
"githubEtag": { "type": "string", "private": true },
"category": {
"type": "relation",
"relation": "manyToOne",
"target": "api::category.category",
"inversedBy": "projects"
},
"techTags": {
"type": "relation",
"relation": "manyToMany",
"target": "api::tech-tag.tech-tag"
},
"contributors": {
"type": "relation",
"relation": "oneToMany",
"target": "api::contributor.contributor",
"mappedBy": "project"
}
}
}Add the inverse projects relation to the Category schema so the bidirectional link resolves:
// src/api/category/content-types/category/schema.json (add to "attributes")
"projects": {
"type": "relation",
"relation": "oneToMany",
"target": "api::project.project",
"mappedBy": "category"
}A few field choices matter here. The description uses type: "blocks", which is Strapi 5's structured rich-text editor. The githubEtag field is marked private, so it never leaks through the REST API or webhook payloads. The languages field is json because the GitHub language breakdown is a key-value map.
Restart Strapi after editing schema files so it picks up the new Content-Types.
Step 3: Build the GitHub Sync Service
This is the core of the backend. The service fetches repository metadata from GitHub, parses the stats, fetches the contributor list, and writes everything back through the Document Service API. It also stores an ETag per project so repeat calls cost nothing against the rate limit.
GitHub's best practices guide is explicit about this: "Making a conditional request does not count against your primary rate limit if a 304 response is returned and the request was made while correctly authorized with an Authorization header." Store the ETag, send it back as If-None-Match, and unchanged repos are free.
The flow works like this. On the first sync, the service has no stored ETag, so it sends a plain authenticated request and saves the etag response header alongside the repository stats. On every later sync, it sends that stored value in an If-None-Match header.
If the repository has not changed, GitHub answers with a 304 status and an empty body, and that exchange does not draw down the 5,000-per-hour ceiling. Only repositories with genuine changes consume quota, which means a showcase with dozens of projects can sync daily and still use a fraction of the allowance.
First, a small helper service that talks to GitHub:
// src/api/project/services/github-sync.ts
import { factories } from '@strapi/strapi';
interface RepoMeta {
stargazers_count: number;
forks_count: number;
open_issues_count: number;
language: string | null;
languages_url: string;
}
interface GitHubContributor {
login: string;
avatar_url: string;
html_url: string;
}
interface SyncResult {
documentId: string;
changed: boolean;
newStars?: number;
newContributors?: number;
}
const GITHUB_API = 'https://api.github.com';
function parseRepoPath(repositoryUrl: string): { owner: string; repo: string } | null {
const match = repositoryUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
if (!match) return null;
return { owner: match[1], repo: match[2] };
}
function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
return {
Authorization: `Bearer ${process.env.GITHUB_PAT}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
...extra,
};
}
export default factories.createCoreService('api::project.project', ({ strapi }) => ({
async syncProject(documentId: string): Promise<SyncResult> {
const project = await strapi.documents('api::project.project').findOne({
documentId,
fields: ['repositoryUrl', 'stars', 'githubEtag'],
});
if (!project?.repositoryUrl) {
strapi.log.warn(`[github-sync] Project ${documentId} has no repositoryUrl`);
return { documentId, changed: false };
}
const parsed = parseRepoPath(project.repositoryUrl);
if (!parsed) {
strapi.log.warn(`[github-sync] Could not parse ${project.repositoryUrl}`);
return { documentId, changed: false };
}
const { owner, repo } = parsed;
const headers = project.githubEtag
? authHeaders({ 'If-None-Match': project.githubEtag })
: authHeaders();
const repoRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers });
if (repoRes.status === 304) {
strapi.log.info(`[github-sync] ${owner}/${repo} unchanged (304, no quota used)`);
return { documentId, changed: false };
}
if (!repoRes.ok) {
strapi.log.error(`[github-sync] ${owner}/${repo} returned ${repoRes.status}`);
return { documentId, changed: false };
}
const meta = (await repoRes.json()) as RepoMeta;
const newEtag = repoRes.headers.get('etag') ?? undefined;
const languagesRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/languages`, {
headers: authHeaders(),
});
const languages = languagesRes.ok ? await languagesRes.json() : {};
const priorStars = project.stars ?? 0;
await strapi.documents('api::project.project').update({
documentId,
data: {
stars: meta.stargazers_count,
forks: meta.forks_count,
openIssues: meta.open_issues_count,
primaryLanguage: meta.language ?? undefined,
languages,
githubEtag: newEtag,
},
});
const newContributors = await this.syncContributors(documentId, owner, repo);
strapi.log.info(
`[github-sync] ${owner}/${repo}: ${meta.stargazers_count} stars ` +
`(+${meta.stargazers_count - priorStars}), +${newContributors} new contributors`
);
return {
documentId,
changed: true,
newStars: meta.stargazers_count - priorStars,
newContributors,
};
},
async syncContributors(documentId: string, owner: string, repo: string): Promise<number> {
const res = await fetch(
`${GITHUB_API}/repos/${owner}/${repo}/contributors?per_page=30`,
{ headers: authHeaders() }
);
if (!res.ok) {
strapi.log.warn(`[github-sync] contributors for ${owner}/${repo} returned ${res.status}`);
return 0;
}
const contributors = (await res.json()) as GitHubContributor[];
let created = 0;
for (const gh of contributors) {
const existing = await strapi.documents('api::contributor.contributor').findMany({
filters: { githubUsername: gh.login },
fields: ['githubUsername'],
});
if (existing.length > 0) {
await strapi.documents('api::contributor.contributor').update({
documentId: existing[0].documentId,
data: {
avatarUrl: gh.avatar_url,
profileUrl: gh.html_url,
project: { connect: [{ documentId }] },
},
});
} else {
await strapi.documents('api::contributor.contributor').create({
data: {
name: gh.login,
githubUsername: gh.login,
avatarUrl: gh.avatar_url,
profileUrl: gh.html_url,
project: { connect: [{ documentId }] },
},
});
created += 1;
}
}
return created;
},
async syncAll(): Promise<SyncResult[]> {
const projects = await strapi.documents('api::project.project').findMany({
fields: ['repositoryUrl'],
});
const results: SyncResult[] = [];
for (const project of projects) {
try {
results.push(await this.syncProject(project.documentId));
} catch (err) {
strapi.log.error(`[github-sync] failed for ${project.documentId}: ${err}`);
results.push({ documentId: project.documentId, changed: false });
}
}
return results;
},
}));A few points worth calling out. Every database operation here uses the Document Service API through strapi.documents(uid), not the deprecated Entity Service. Updates address records by documentId, the 24-character persistent identifier that Strapi 5 uses everywhere.
The contributor relation update uses the connect syntax from the REST relations reference, which adds the link without clobbering existing ones. The syncAll method wraps each project in a try/catch so a single failed repo never aborts the whole run or overwrites good data.
Error handling is deliberate here. Each repository syncs inside its own try/catch within syncAll, so a deleted repo, a renamed owner, or a transient GitHub outage logs a warning and moves on instead of aborting the run. Because the update only writes when a non-304, non-error response arrives, a failed fetch never overwrites the last good numbers with zeros.
Notice that the repos request sends If-None-Match when an ETag exists. The languages and contributors calls do not, since those endpoints change less predictably and a fresh fetch keeps the logic simple while staying well inside the 5,000 requests per hour ceiling.
Step 4: Schedule Daily Syncing with Cron Tasks
Strapi 5 runs scheduled jobs through config/cron-tasks.ts with no external scheduler. The cron documentation recommends the object format over the key format, because anonymous jobs are harder to disable.
// config/cron-tasks.ts
export default {
syncGitHubData: {
task: async ({ strapi }) => {
strapi.log.info('[cron] Starting daily GitHub sync');
const results = await strapi
.service('api::project.github-sync')
.syncAll();
const changed = results.filter((r) => r.changed);
const totalNewStars = changed.reduce((sum, r) => sum + (r.newStars ?? 0), 0);
const totalNewContributors = changed.reduce(
(sum, r) => sum + (r.newContributors ?? 0),
0
);
strapi.log.info(
`[cron] GitHub sync complete: ${changed.length}/${results.length} projects updated, ` +
`+${totalNewStars} stars, +${totalNewContributors} contributors`
);
},
options: {
rule: '0 0 3 * * *',
},
},
};The cron rule follows the standard format documented in the server configuration page, where the optional leading field is seconds. This runs at 3 a.m. server time daily. Because syncAll swallows per-project errors and the conditional request skips unchanged repos, a GitHub outage or a single bad URL does not wipe existing data.
Enable cron in the server config:
// config/server.ts
import cronTasks from './cron-tasks';
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
cron: {
enabled: true,
tasks: cronTasks,
},
});Add your token to the backend environment so the service can authenticate:
# .env
GITHUB_PAT=ghp_your_personal_access_tokenStep 5: Configure Webhooks for Rebuild Triggers
A static site is only as fresh as its last build. Strapi webhooks close that gap by pinging your deploy platform's build hook whenever content changes. Set this up under General > Settings > Global Settings - Webhook in the Admin Panel.
Create a webhook pointing at the build hook URL from Netlify, Vercel, or Cloudflare Pages. Select the events you care about. For a showcase site, entry.publish and entry.update on the Project Content-Type are the useful ones. The webhooks documentation lists every available event.
When a project is published, Strapi sends an HTTP POST to your build hook with this shape:
// Example webhook POST body from Strapi
{
"event": "entry.publish",
"createdAt": "2025-01-10T08:59:35.796Z",
"model": "project",
"entry": {
"id": 1,
"name": "Showcase Engine",
"slug": "showcase-engine",
"publishedAt": "2025-01-10T09:00:12.134Z",
"updatedAt": "2025-01-10T08:58:26.210Z"
}
}The X-Strapi-Event header carries the event type, and private fields like githubEtag are never included in the payload. One thing to keep in mind: the User content-type does not trigger webhooks, so if you ever sync user data you need a lifecycle hook instead. That does not affect this build, but it is worth knowing.
You can also set default headers for every webhook in config/server.ts if your build hook needs a shared secret:
// config/server.ts
export default ({ env }) => ({
host: env('HOST', '0.0.0.0'),
port: env.int('PORT', 1337),
app: {
keys: env.array('APP_KEYS'),
},
cron: {
enabled: true,
tasks: cronTasks,
},
webhooks: {
defaultHeaders: {
'X-Build-Secret': env('BUILD_HOOK_SECRET', ''),
},
},
});Building the Astro Frontend
With the backend serving content and syncing GitHub data, the frontend fetches everything at build time and renders static HTML. The four steps below scaffold the Astro project, build the listing and detail pages, and add a single interactive island for tag filtering.
Step 1: Set Up the Astro Project
Scaffold a new Astro project in a separate directory from the backend.
npm create astro@latest showcase-frontendChoose the empty template and TypeScript when prompted. Astro 6 requires Node.js 22.12.0 or higher, so if the installer warns about your Node version, switch to v22 before continuing.
Tailwind CSS v4 integrates through a Vite plugin. The old @astrojs/tailwind integration is deprecated, so install the packages directly:
cd showcase-frontend
npm install tailwindcss @tailwindcss/vite
npm install react react-dom @astrojs/reactThe React packages power the tag filter island later. Wire up Tailwind and React in the Astro config:
// astro.config.mjs
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
import react from '@astrojs/react';
export default defineConfig({
site: 'https://your-showcase-site.com',
integrations: [react()],
vite: {
plugins: [tailwindcss()],
},
});Create the Tailwind entry stylesheet. Tailwind v4 needs only a single import.
/* src/styles/global.css */
@import "tailwindcss";Set up environment variables. Astro reads these through import.meta.env, not process.env. Variables prefixed with PUBLIC_ reach client-side code; unprefixed ones stay build-time only, per the environment variables guide.
# .env
PUBLIC_STRAPI_URL=http://localhost:1337Add TypeScript IntelliSense for those variables:
// src/env.d.ts
interface ImportMetaEnv {
readonly PUBLIC_STRAPI_URL: string;
readonly STRAPI_API_TOKEN: string;
readonly GITHUB_PAT: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}Define the response shape so the rest of the build is type-safe. Strapi 5 returns a flat REST format with no data.attributes wrapper, and every entry carries a documentId.
// src/types/strapi.ts
export interface StrapiMedia {
url: string;
alternativeText: string | null;
width: number;
height: number;
}
export interface TechTag {
documentId: string;
name: string;
slug: string;
icon: string | null;
color: string | null;
}
export interface Contributor {
documentId: string;
name: string;
githubUsername: string;
avatarUrl: string | null;
profileUrl: string | null;
}
export interface Project {
documentId: string;
name: string;
slug: string;
tagline: string | null;
description: unknown[];
repositoryUrl: string;
liveDemoUrl: string | null;
screenshots: StrapiMedia[];
featured: boolean;
stars: number;
forks: number;
openIssues: number;
primaryLanguage: string | null;
languages: Record<string, number> | null;
techTags: TechTag[];
contributors: Contributor[];
}
export interface StrapiCollection<T> {
data: T[];
meta: {
pagination: { page: number; pageSize: number; pageCount: number; total: number };
};
}Step 2: Create the Project Listing Page
Astro components are server-only by default. Data fetching happens in the frontmatter at build time, not in the browser: your deployed site fetches data once, at build time.
Populate must be explicit in Strapi 5. Wildcard populate=* works but is discouraged for production, so the query targets exactly the relations and fields the page needs.
---
// src/pages/index.astro
import "../styles/global.css";
import type { Project, StrapiCollection } from "../types/strapi";
import ProjectGrid from "../components/ProjectGrid.tsx";
const query = [
"populate[screenshots][fields][0]=url",
"populate[screenshots][fields][1]=alternativeText",
"populate[techTags][fields][0]=name",
"populate[techTags][fields][1]=slug",
"populate[techTags][fields][2]=color",
].join("&");
const response = await fetch(
`${import.meta.env.PUBLIC_STRAPI_URL}/api/projects?${query}`
);
const { data: projects } = (await response.json()) as StrapiCollection<Project>;
const allTags = Array.from(
new Map(
projects.flatMap((p) => p.techTags).map((t) => [t.slug, t])
).values()
);
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Open Source Project Showcase</title>
</head>
<body class="bg-slate-50 text-slate-900">
<main class="mx-auto max-w-6xl px-6 py-12">
<h1 class="mb-2 text-4xl font-bold">Open Source Projects</h1>
<p class="mb-8 text-slate-600">
A showcase of projects, synced live from GitHub.
</p>
<ProjectGrid client:load projects={projects} allTags={allTags} />
</main>
</body>
</html>The ProjectGrid component handles both rendering and the interactive filter. We will write it in Step 4. For now, the page fetches projects with targeted populate and hands them down as props.
Step 3: Build Project Detail Pages
Dynamic routes use getStaticPaths(), which Astro runs once at build time in an isolated scope. The routing reference notes two things that matter in Astro 6: params values must be strings, and you cannot reference outer-scope variables inside the function except for imports.
This query populates the relations the detail page renders: tech tags, contributors, screenshots, and the category.
---
// src/pages/projects/[slug].astro
import "../../styles/global.css";
import type { GetStaticPaths } from "astro";
import type { Project, StrapiCollection } from "../../types/strapi";
export const getStaticPaths = (async () => {
const query = [
"populate[screenshots][fields][0]=url",
"populate[screenshots][fields][1]=alternativeText",
"populate[techTags][fields][0]=name",
"populate[techTags][fields][1]=color",
"populate[contributors][fields][0]=name",
"populate[contributors][fields][1]=githubUsername",
"populate[contributors][fields][2]=avatarUrl",
"populate[contributors][fields][3]=profileUrl",
].join("&");
const response = await fetch(
`${import.meta.env.PUBLIC_STRAPI_URL}/api/projects?${query}`
);
const { data: projects } = (await response.json()) as StrapiCollection<Project>;
return projects.map((project) => ({
params: { slug: project.slug },
props: { project },
}));
}) satisfies GetStaticPaths;
const { project } = Astro.props;
const strapiUrl = import.meta.env.PUBLIC_STRAPI_URL;
---
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{project.name}</title>
</head>
<body class="bg-slate-50 text-slate-900">
<main class="mx-auto max-w-4xl px-6 py-12">
<a href="/" class="text-sm text-blue-600">← Back to all projects</a>
<h1 class="mt-4 mb-2 text-4xl font-bold">{project.name}</h1>
{project.tagline && <p class="mb-6 text-lg text-slate-600">{project.tagline}</p>}
<div class="mb-8 flex flex-wrap gap-4">
<span class="rounded bg-amber-100 px-3 py-1 text-sm">
⭐ {project.stars} stars
</span>
<span class="rounded bg-slate-200 px-3 py-1 text-sm">
🍴 {project.forks} forks
</span>
<span class="rounded bg-red-100 px-3 py-1 text-sm">
🐛 {project.openIssues} open issues
</span>
{project.primaryLanguage && (
<span class="rounded bg-blue-100 px-3 py-1 text-sm">
{project.primaryLanguage}
</span>
)}
</div>
<div class="mb-8 flex flex-wrap gap-2">
{project.techTags.map((tag) => (
<span
class="rounded-full px-3 py-1 text-xs font-medium"
style={`background-color: ${tag.color ?? "#e2e8f0"}`}
>
{tag.name}
</span>
))}
</div>
{project.screenshots.length > 0 && (
<div class="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
{project.screenshots.map((shot) => (
<img
src={`${strapiUrl}${shot.url}`}
alt={shot.alternativeText ?? project.name}
width={shot.width}
height={shot.height}
class="rounded-lg border border-slate-200"
/>
))}
</div>
)}
{project.contributors.length > 0 && (
<section class="mb-8">
<h2 class="mb-4 text-2xl font-semibold">Contributors</h2>
<div class="flex flex-wrap gap-4">
{project.contributors.map((c) => (
<a href={c.profileUrl ?? "#"} class="flex items-center gap-2">
{c.avatarUrl && (
<img
src={c.avatarUrl}
alt={c.name}
width={32}
height={32}
class="rounded-full"
/>
)}
<span class="text-sm">{c.name}</span>
</a>
))}
</div>
</section>
)}
<div class="flex gap-4">
<a
href={project.repositoryUrl}
class="rounded bg-slate-900 px-4 py-2 text-sm text-white"
>
View on GitHub
</a>
{project.liveDemoUrl && (
<a
href={project.liveDemoUrl}
class="rounded border border-slate-300 px-4 py-2 text-sm"
>
Live Demo
</a>
)}
</div>
</main>
</body>
</html>To render the Blocks editor description, install the official renderer in the frontend and drop it into the template:
npm install @strapi/blocks-react-renderer---
// src/pages/projects/[slug].astro (add to template)
import BlocksRenderer from "../../components/Description.tsx";
---
<article class="prose mb-8 max-w-none">
<BlocksRenderer content={project.description} />
</article>Step 4: Add an Interactive Tag Filter with Astro Islands
Everything so far is static HTML. The tag filter is the one piece that needs to run in the browser, so it becomes an island. The islands documentation describes the contract: Astro hydrates exactly what you mark and leaves the rest as static HTML. The client:load directive in the listing page tells Astro to ship and run this component's JavaScript on page load.
Static-first rendering matters for a showcase site because the audience is often other developers scanning quickly from search results or a link in a README. A page that arrives as finished HTML paints instantly and ranks well, with no loading spinner while a framework boots.
Astro inverts the usual default: nothing hydrates unless you ask for it. Marking only the filter with client:load keeps the rest of the page as plain markup, so the JavaScript budget stays tied to actual interactivity rather than to the framework you happened to pick.
// src/components/ProjectGrid.tsx
import { useMemo, useState } from "react";
import type { Project, TechTag } from "../types/strapi";
interface ProjectGridProps {
projects: Project[];
allTags: TechTag[];
}
export default function ProjectGrid({ projects, allTags }: ProjectGridProps) {
const [activeTag, setActiveTag] = useState<string | null>(null);
const strapiUrl = import.meta.env.PUBLIC_STRAPI_URL;
const visible = useMemo(() => {
if (!activeTag) return projects;
return projects.filter((p) => p.techTags.some((t) => t.slug === activeTag));
}, [activeTag, projects]);
return (
<div>
<div className="mb-8 flex flex-wrap gap-2">
<button
onClick={() => setActiveTag(null)}
className={`rounded-full px-3 py-1 text-sm ${!activeTag ? "bg-slate-900 text-white" : "bg-slate-200"}`}
>
All
</button>
{allTags.map((tag) => (
<button
key={tag.slug}
onClick={() => setActiveTag(tag.slug)}
className={`rounded-full px-3 py-1 text-sm ${activeTag === tag.slug ? "bg-slate-900 text-white" : "bg-slate-200"}`}
>
{tag.name}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
{visible.map((project) => (
<a
key={project.documentId}
href={`/projects/${project.slug}`}
className="block overflow-hidden rounded-lg border border-slate-200 bg-white transition hover:shadow-md"
>
{project.screenshots[0] && (
<img
src={`${strapiUrl}${project.screenshots[0].url}`}
alt={project.screenshots[0].alternativeText ?? project.name}
className="h-40 w-full object-cover"
/>
)}
<div className="p-4">
<h2 className="text-lg font-semibold">{project.name}</h2>
{project.tagline && (
<p className="mt-1 text-sm text-slate-600">{project.tagline}</p>
)}
<div className="mt-3 flex flex-wrap gap-1">
{project.techTags.map((tag) => (
<span
key={tag.slug}
className="rounded-full px-2 py-0.5 text-xs"
style={{ backgroundColor: tag.color ?? "#e2e8f0" }}
>
{tag.name}
</span>
))}
</div>
<div className="mt-3 text-sm text-slate-500">⭐ {project.stars}</div>
</div>
</a>
))}
</div>
</div>
);
}This component receives its data as props from the build-time fetch in index.astro, so there is no client-side network call. The only JavaScript on the page is the filter logic and React's runtime, which Astro sends once regardless of how many islands use it. If you wanted an even smaller payload, swapping @astrojs/react for @astrojs/preact gives the same API in a 3 kB package.
Putting It All Together
With both halves built, walk through the full loop.
Start both servers. In the backend directory, run npm run develop. In the frontend directory, run npm run dev.
In the Strapi Admin Panel, open the Project Content-Type and create an entry. Give it a name, slug, tagline, a description in the Blocks editor, and the repository URL for a real GitHub repo. Attach a screenshot or two from the Media Library, link a few tech tags, and assign a category. Save and publish.
Trigger a manual sync to pull GitHub metadata before the cron task would run. Rather than wait until 3 a.m., expose a dedicated route that calls the same syncAll service. Add a controller method and route to the Project API so a single authenticated request fills in the live data on demand.
// src/api/project/controllers/project.ts
import { factories } from '@strapi/strapi';
export default factories.createCoreController('api::project.project', ({ strapi }) => ({
async sync(ctx) {
const results = await strapi
.service('api::project.github-sync')
.syncAll();
const changed = results.filter((r) => r.changed);
ctx.body = {
total: results.length,
updated: changed.length,
};
},
}));// src/api/project/routes/custom-project.ts
export default {
routes: [
{
method: 'POST',
path: '/projects/sync',
handler: 'project.sync',
config: {
auth: false,
},
},
],
};With the backend running, fire the sync with a single request:
curl -X POST http://localhost:1337/api/projects/syncThe response reports how many projects were checked and how many changed. Once the sync runs, each project's stars, forks, openIssues, and primaryLanguage fields fill in, and contributor entries appear linked to the project. In production, protect this route with an API token or remove auth: false so only authorized callers can trigger it.
Build the Astro site:
npm run build
npm run previewOpen the preview URL. The listing page shows your project card with the screenshot, tech tags, and live star count. The tag filter buttons work without a page reload, since that island is hydrated client-side. Click into the project and the detail page renders the Blocks description, the GitHub stat badges, the contributor avatars, and the screenshot gallery.
Now publish an update. Change the tagline in Strapi and republish. The entry.publish webhook fires an HTTP POST to your deploy platform's build hook, which kicks off a fresh build. Your static site picks up the change on the next deploy, and the daily cron task keeps the GitHub numbers current without any manual step.
How Strapi Powers This
Strapi 5 handles both sides of this showcase: the editorial content that humans write and the live metadata that machines fetch. The Content-Types Builder models projects, tags, and contributors with explicit relations, while the Document Service API gives your custom sync service a clean interface for writing GitHub data back to each record by documentId. Cron tasks run the sync on a schedule with no external job runner, and webhooks push content changes to your deploy platform so the static site stays current.
The Media Library stores screenshots, and the Blocks editor gives editors structured rich text without touching code. Together, these features turn Strapi into the single backend for both editorial workflow and automated data pipelines.
Ready to try it? Start building with Strapi 5 or explore the full feature set.
From here, a few directions extend the showcase:
- Deploy the backend to Strapi Cloud and the frontend to Cloudflare Pages, then point the production webhook at the Pages build hook.
- Add a static client-side search so visitors can filter by name or description without a backend call.
- Build an RSS feed for newly added projects.
- Add a contributor leaderboard page that aggregates contributors across all projects, sorted by project count.
- Dig deeper through the Strapi 5 documentation and the Astro documentation, and review the official Astro integration for alternative data-fetching patterns like the Strapi Client SDK.
Get Started in Minutes
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.