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:
config/cron-tasks.ts with no external job runner.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:
getStaticPaths().Before starting, confirm your environment matches these versions. They are pinned for a reason.
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.
Create the project with the official installer.
1npx 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:
1cd showcase-backend
2npm run developStrapi opens the Admin Panel at http://localhost:1337/admin. Create your administrator account before moving on.
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.
1// src/api/category/content-types/category/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "categories",
5 "info": {
6 "singularName": "category",
7 "pluralName": "categories",
8 "displayName": "Category"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "attributes": {
14 "name": { "type": "string", "required": true },
15 "slug": { "type": "uid", "targetField": "name", "required": true },
16 "description": { "type": "text" },
17 "sortOrder": { "type": "integer", "default": 0 }
18 }
19}Next, the TechTag Collection Type:
1// src/api/tech-tag/content-types/tech-tag/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "tech_tags",
5 "info": {
6 "singularName": "tech-tag",
7 "pluralName": "tech-tags",
8 "displayName": "TechTag"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "attributes": {
14 "name": { "type": "string", "required": true },
15 "slug": { "type": "uid", "targetField": "name", "required": true },
16 "icon": { "type": "string" },
17 "color": { "type": "string" }
18 }
19}The Contributor Collection Type stores GitHub identity fields. The sync service writes to these later.
1// src/api/contributor/content-types/contributor/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "contributors",
5 "info": {
6 "singularName": "contributor",
7 "pluralName": "contributors",
8 "displayName": "Contributor"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "attributes": {
14 "name": { "type": "string", "required": true },
15 "githubUsername": { "type": "string", "required": true, "unique": true },
16 "avatarUrl": { "type": "string" },
17 "profileUrl": { "type": "string" },
18 "project": {
19 "type": "relation",
20 "relation": "manyToOne",
21 "target": "api::project.project",
22 "inversedBy": "contributors"
23 }
24 }
25}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.
1// src/api/project/content-types/project/schema.json
2{
3 "kind": "collectionType",
4 "collectionName": "projects",
5 "info": {
6 "singularName": "project",
7 "pluralName": "projects",
8 "displayName": "Project"
9 },
10 "options": {
11 "draftAndPublish": true
12 },
13 "attributes": {
14 "name": { "type": "string", "required": true },
15 "slug": { "type": "uid", "targetField": "name", "required": true },
16 "tagline": { "type": "string" },
17 "description": { "type": "blocks" },
18 "repositoryUrl": { "type": "string", "required": true },
19 "liveDemoUrl": { "type": "string" },
20 "screenshots": {
21 "type": "media",
22 "multiple": true,
23 "allowedTypes": ["images"]
24 },
25 "featured": { "type": "boolean", "default": false },
26 "stars": { "type": "integer", "default": 0 },
27 "forks": { "type": "integer", "default": 0 },
28 "openIssues": { "type": "integer", "default": 0 },
29 "primaryLanguage": { "type": "string" },
30 "languages": { "type": "json" },
31 "githubEtag": { "type": "string", "private": true },
32 "category": {
33 "type": "relation",
34 "relation": "manyToOne",
35 "target": "api::category.category",
36 "inversedBy": "projects"
37 },
38 "techTags": {
39 "type": "relation",
40 "relation": "manyToMany",
41 "target": "api::tech-tag.tech-tag"
42 },
43 "contributors": {
44 "type": "relation",
45 "relation": "oneToMany",
46 "target": "api::contributor.contributor",
47 "mappedBy": "project"
48 }
49 }
50}Add the inverse projects relation to the Category schema so the bidirectional link resolves:
1// src/api/category/content-types/category/schema.json (add to "attributes")
2"projects": {
3 "type": "relation",
4 "relation": "oneToMany",
5 "target": "api::project.project",
6 "mappedBy": "category"
7}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.
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:
1// src/api/project/services/github-sync.ts
2import { factories } from '@strapi/strapi';
3
4interface RepoMeta {
5 stargazers_count: number;
6 forks_count: number;
7 open_issues_count: number;
8 language: string | null;
9 languages_url: string;
10}
11
12interface GitHubContributor {
13 login: string;
14 avatar_url: string;
15 html_url: string;
16}
17
18interface SyncResult {
19 documentId: string;
20 changed: boolean;
21 newStars?: number;
22 newContributors?: number;
23}
24
25const GITHUB_API = 'https://api.github.com';
26
27function parseRepoPath(repositoryUrl: string): { owner: string; repo: string } | null {
28 const match = repositoryUrl.match(/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?\/?$/);
29 if (!match) return null;
30 return { owner: match[1], repo: match[2] };
31}
32
33function authHeaders(extra: Record<string, string> = {}): Record<string, string> {
34 return {
35 Authorization: `Bearer ${process.env.GITHUB_PAT}`,
36 Accept: 'application/vnd.github+json',
37 'X-GitHub-Api-Version': '2022-11-28',
38 ...extra,
39 };
40}
41
42export default factories.createCoreService('api::project.project', ({ strapi }) => ({
43 async syncProject(documentId: string): Promise<SyncResult> {
44 const project = await strapi.documents('api::project.project').findOne({
45 documentId,
46 fields: ['repositoryUrl', 'stars', 'githubEtag'],
47 });
48
49 if (!project?.repositoryUrl) {
50 strapi.log.warn(`[github-sync] Project ${documentId} has no repositoryUrl`);
51 return { documentId, changed: false };
52 }
53
54 const parsed = parseRepoPath(project.repositoryUrl);
55 if (!parsed) {
56 strapi.log.warn(`[github-sync] Could not parse ${project.repositoryUrl}`);
57 return { documentId, changed: false };
58 }
59
60 const { owner, repo } = parsed;
61 const headers = project.githubEtag
62 ? authHeaders({ 'If-None-Match': project.githubEtag })
63 : authHeaders();
64
65 const repoRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}`, { headers });
66
67 if (repoRes.status === 304) {
68 strapi.log.info(`[github-sync] ${owner}/${repo} unchanged (304, no quota used)`);
69 return { documentId, changed: false };
70 }
71
72 if (!repoRes.ok) {
73 strapi.log.error(`[github-sync] ${owner}/${repo} returned ${repoRes.status}`);
74 return { documentId, changed: false };
75 }
76
77 const meta = (await repoRes.json()) as RepoMeta;
78 const newEtag = repoRes.headers.get('etag') ?? undefined;
79
80 const languagesRes = await fetch(`${GITHUB_API}/repos/${owner}/${repo}/languages`, {
81 headers: authHeaders(),
82 });
83 const languages = languagesRes.ok ? await languagesRes.json() : {};
84
85 const priorStars = project.stars ?? 0;
86
87 await strapi.documents('api::project.project').update({
88 documentId,
89 data: {
90 stars: meta.stargazers_count,
91 forks: meta.forks_count,
92 openIssues: meta.open_issues_count,
93 primaryLanguage: meta.language ?? undefined,
94 languages,
95 githubEtag: newEtag,
96 },
97 });
98
99 const newContributors = await this.syncContributors(documentId, owner, repo);
100
101 strapi.log.info(
102 `[github-sync] ${owner}/${repo}: ${meta.stargazers_count} stars ` +
103 `(+${meta.stargazers_count - priorStars}), +${newContributors} new contributors`
104 );
105
106 return {
107 documentId,
108 changed: true,
109 newStars: meta.stargazers_count - priorStars,
110 newContributors,
111 };
112 },
113
114 async syncContributors(documentId: string, owner: string, repo: string): Promise<number> {
115 const res = await fetch(
116 `${GITHUB_API}/repos/${owner}/${repo}/contributors?per_page=30`,
117 { headers: authHeaders() }
118 );
119
120 if (!res.ok) {
121 strapi.log.warn(`[github-sync] contributors for ${owner}/${repo} returned ${res.status}`);
122 return 0;
123 }
124
125 const contributors = (await res.json()) as GitHubContributor[];
126 let created = 0;
127
128 for (const gh of contributors) {
129 const existing = await strapi.documents('api::contributor.contributor').findMany({
130 filters: { githubUsername: gh.login },
131 fields: ['githubUsername'],
132 });
133
134 if (existing.length > 0) {
135 await strapi.documents('api::contributor.contributor').update({
136 documentId: existing[0].documentId,
137 data: {
138 avatarUrl: gh.avatar_url,
139 profileUrl: gh.html_url,
140 project: { connect: [{ documentId }] },
141 },
142 });
143 } else {
144 await strapi.documents('api::contributor.contributor').create({
145 data: {
146 name: gh.login,
147 githubUsername: gh.login,
148 avatarUrl: gh.avatar_url,
149 profileUrl: gh.html_url,
150 project: { connect: [{ documentId }] },
151 },
152 });
153 created += 1;
154 }
155 }
156
157 return created;
158 },
159
160 async syncAll(): Promise<SyncResult[]> {
161 const projects = await strapi.documents('api::project.project').findMany({
162 fields: ['repositoryUrl'],
163 });
164
165 const results: SyncResult[] = [];
166 for (const project of projects) {
167 try {
168 results.push(await this.syncProject(project.documentId));
169 } catch (err) {
170 strapi.log.error(`[github-sync] failed for ${project.documentId}: ${err}`);
171 results.push({ documentId: project.documentId, changed: false });
172 }
173 }
174 return results;
175 },
176}));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.
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.
1// config/cron-tasks.ts
2export default {
3 syncGitHubData: {
4 task: async ({ strapi }) => {
5 strapi.log.info('[cron] Starting daily GitHub sync');
6 const results = await strapi
7 .service('api::project.github-sync')
8 .syncAll();
9
10 const changed = results.filter((r) => r.changed);
11 const totalNewStars = changed.reduce((sum, r) => sum + (r.newStars ?? 0), 0);
12 const totalNewContributors = changed.reduce(
13 (sum, r) => sum + (r.newContributors ?? 0),
14 0
15 );
16
17 strapi.log.info(
18 `[cron] GitHub sync complete: ${changed.length}/${results.length} projects updated, ` +
19 `+${totalNewStars} stars, +${totalNewContributors} contributors`
20 );
21 },
22 options: {
23 rule: '0 0 3 * * *',
24 },
25 },
26};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:
1// config/server.ts
2import cronTasks from './cron-tasks';
3
4export default ({ env }) => ({
5 host: env('HOST', '0.0.0.0'),
6 port: env.int('PORT', 1337),
7 app: {
8 keys: env.array('APP_KEYS'),
9 },
10 cron: {
11 enabled: true,
12 tasks: cronTasks,
13 },
14});Add your token to the backend environment so the service can authenticate:
1# .env
2GITHUB_PAT=ghp_your_personal_access_tokenA 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:
1// Example webhook POST body from Strapi
2{
3 "event": "entry.publish",
4 "createdAt": "2025-01-10T08:59:35.796Z",
5 "model": "project",
6 "entry": {
7 "id": 1,
8 "name": "Showcase Engine",
9 "slug": "showcase-engine",
10 "publishedAt": "2025-01-10T09:00:12.134Z",
11 "updatedAt": "2025-01-10T08:58:26.210Z"
12 }
13}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:
1// config/server.ts
2export default ({ env }) => ({
3 host: env('HOST', '0.0.0.0'),
4 port: env.int('PORT', 1337),
5 app: {
6 keys: env.array('APP_KEYS'),
7 },
8 cron: {
9 enabled: true,
10 tasks: cronTasks,
11 },
12 webhooks: {
13 defaultHeaders: {
14 'X-Build-Secret': env('BUILD_HOOK_SECRET', ''),
15 },
16 },
17});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.
Scaffold a new Astro project in a separate directory from the backend.
1npm 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:
1cd showcase-frontend
2npm install tailwindcss @tailwindcss/vite
3npm install react react-dom @astrojs/reactThe React packages power the tag filter island later. Wire up Tailwind and React in the Astro config:
1// astro.config.mjs
2import { defineConfig } from 'astro/config';
3import tailwindcss from '@tailwindcss/vite';
4import react from '@astrojs/react';
5
6export default defineConfig({
7 site: 'https://your-showcase-site.com',
8 integrations: [react()],
9 vite: {
10 plugins: [tailwindcss()],
11 },
12});Create the Tailwind entry stylesheet. Tailwind v4 needs only a single import.
1/* src/styles/global.css */
2@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.
1# .env
2PUBLIC_STRAPI_URL=http://localhost:1337Add TypeScript IntelliSense for those variables:
1// src/env.d.ts
2interface ImportMetaEnv {
3 readonly PUBLIC_STRAPI_URL: string;
4 readonly STRAPI_API_TOKEN: string;
5 readonly GITHUB_PAT: string;
6}
7
8interface ImportMeta {
9 readonly env: ImportMetaEnv;
10}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.
1// src/types/strapi.ts
2export interface StrapiMedia {
3 url: string;
4 alternativeText: string | null;
5 width: number;
6 height: number;
7}
8
9export interface TechTag {
10 documentId: string;
11 name: string;
12 slug: string;
13 icon: string | null;
14 color: string | null;
15}
16
17export interface Contributor {
18 documentId: string;
19 name: string;
20 githubUsername: string;
21 avatarUrl: string | null;
22 profileUrl: string | null;
23}
24
25export interface Project {
26 documentId: string;
27 name: string;
28 slug: string;
29 tagline: string | null;
30 description: unknown[];
31 repositoryUrl: string;
32 liveDemoUrl: string | null;
33 screenshots: StrapiMedia[];
34 featured: boolean;
35 stars: number;
36 forks: number;
37 openIssues: number;
38 primaryLanguage: string | null;
39 languages: Record<string, number> | null;
40 techTags: TechTag[];
41 contributors: Contributor[];
42}
43
44export interface StrapiCollection<T> {
45 data: T[];
46 meta: {
47 pagination: { page: number; pageSize: number; pageCount: number; total: number };
48 };
49}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.
1---
2// src/pages/index.astro
3import "../styles/global.css";
4import type { Project, StrapiCollection } from "../types/strapi";
5import ProjectGrid from "../components/ProjectGrid.tsx";
6
7const query = [
8 "populate[screenshots][fields][0]=url",
9 "populate[screenshots][fields][1]=alternativeText",
10 "populate[techTags][fields][0]=name",
11 "populate[techTags][fields][1]=slug",
12 "populate[techTags][fields][2]=color",
13].join("&");
14
15const response = await fetch(
16 `${import.meta.env.PUBLIC_STRAPI_URL}/api/projects?${query}`
17);
18const { data: projects } = (await response.json()) as StrapiCollection<Project>;
19
20const allTags = Array.from(
21 new Map(
22 projects.flatMap((p) => p.techTags).map((t) => [t.slug, t])
23 ).values()
24);
25---
26
27<html lang="en">
28 <head>
29 <meta charset="utf-8" />
30 <meta name="viewport" content="width=device-width, initial-scale=1" />
31 <title>Open Source Project Showcase</title>
32 </head>
33 <body class="bg-slate-50 text-slate-900">
34 <main class="mx-auto max-w-6xl px-6 py-12">
35 <h1 class="mb-2 text-4xl font-bold">Open Source Projects</h1>
36 <p class="mb-8 text-slate-600">
37 A showcase of projects, synced live from GitHub.
38 </p>
39 <ProjectGrid client:load projects={projects} allTags={allTags} />
40 </main>
41 </body>
42</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.
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.
1---
2// src/pages/projects/[slug].astro
3import "../../styles/global.css";
4import type { GetStaticPaths } from "astro";
5import type { Project, StrapiCollection } from "../../types/strapi";
6
7export const getStaticPaths = (async () => {
8 const query = [
9 "populate[screenshots][fields][0]=url",
10 "populate[screenshots][fields][1]=alternativeText",
11 "populate[techTags][fields][0]=name",
12 "populate[techTags][fields][1]=color",
13 "populate[contributors][fields][0]=name",
14 "populate[contributors][fields][1]=githubUsername",
15 "populate[contributors][fields][2]=avatarUrl",
16 "populate[contributors][fields][3]=profileUrl",
17 ].join("&");
18
19 const response = await fetch(
20 `${import.meta.env.PUBLIC_STRAPI_URL}/api/projects?${query}`
21 );
22 const { data: projects } = (await response.json()) as StrapiCollection<Project>;
23
24 return projects.map((project) => ({
25 params: { slug: project.slug },
26 props: { project },
27 }));
28}) satisfies GetStaticPaths;
29
30const { project } = Astro.props;
31const strapiUrl = import.meta.env.PUBLIC_STRAPI_URL;
32---
33
34<html lang="en">
35 <head>
36 <meta charset="utf-8" />
37 <meta name="viewport" content="width=device-width, initial-scale=1" />
38 <title>{project.name}</title>
39 </head>
40 <body class="bg-slate-50 text-slate-900">
41 <main class="mx-auto max-w-4xl px-6 py-12">
42 <a href="/" class="text-sm text-blue-600">← Back to all projects</a>
43 <h1 class="mt-4 mb-2 text-4xl font-bold">{project.name}</h1>
44 {project.tagline && <p class="mb-6 text-lg text-slate-600">{project.tagline}</p>}
45
46 <div class="mb-8 flex flex-wrap gap-4">
47 <span class="rounded bg-amber-100 px-3 py-1 text-sm">
48 ⭐ {project.stars} stars
49 </span>
50 <span class="rounded bg-slate-200 px-3 py-1 text-sm">
51 🍴 {project.forks} forks
52 </span>
53 <span class="rounded bg-red-100 px-3 py-1 text-sm">
54 🐛 {project.openIssues} open issues
55 </span>
56 {project.primaryLanguage && (
57 <span class="rounded bg-blue-100 px-3 py-1 text-sm">
58 {project.primaryLanguage}
59 </span>
60 )}
61 </div>
62
63 <div class="mb-8 flex flex-wrap gap-2">
64 {project.techTags.map((tag) => (
65 <span
66 class="rounded-full px-3 py-1 text-xs font-medium"
67 style={`background-color: ${tag.color ?? "#e2e8f0"}`}
68 >
69 {tag.name}
70 </span>
71 ))}
72 </div>
73
74 {project.screenshots.length > 0 && (
75 <div class="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
76 {project.screenshots.map((shot) => (
77 <img
78 src={`${strapiUrl}${shot.url}`}
79 alt={shot.alternativeText ?? project.name}
80 width={shot.width}
81 height={shot.height}
82 class="rounded-lg border border-slate-200"
83 />
84 ))}
85 </div>
86 )}
87
88 {project.contributors.length > 0 && (
89 <section class="mb-8">
90 <h2 class="mb-4 text-2xl font-semibold">Contributors</h2>
91 <div class="flex flex-wrap gap-4">
92 {project.contributors.map((c) => (
93 <a href={c.profileUrl ?? "#"} class="flex items-center gap-2">
94 {c.avatarUrl && (
95 <img
96 src={c.avatarUrl}
97 alt={c.name}
98 width={32}
99 height={32}
100 class="rounded-full"
101 />
102 )}
103 <span class="text-sm">{c.name}</span>
104 </a>
105 ))}
106 </div>
107 </section>
108 )}
109
110 <div class="flex gap-4">
111 <a
112 href={project.repositoryUrl}
113 class="rounded bg-slate-900 px-4 py-2 text-sm text-white"
114 >
115 View on GitHub
116 </a>
117 {project.liveDemoUrl && (
118 <a
119 href={project.liveDemoUrl}
120 class="rounded border border-slate-300 px-4 py-2 text-sm"
121 >
122 Live Demo
123 </a>
124 )}
125 </div>
126 </main>
127 </body>
128</html>To render the Blocks editor description, install the official renderer in the frontend and drop it into the template:
1npm install @strapi/blocks-react-renderer1---
2// src/pages/projects/[slug].astro (add to template)
3import BlocksRenderer from "../../components/Description.tsx";
4---
5<article class="prose mb-8 max-w-none">
6 <BlocksRenderer content={project.description} />
7</article>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.
1// src/components/ProjectGrid.tsx
2import { useMemo, useState } from "react";
3import type { Project, TechTag } from "../types/strapi";
4
5interface ProjectGridProps {
6 projects: Project[];
7 allTags: TechTag[];
8}
9
10export default function ProjectGrid({ projects, allTags }: ProjectGridProps) {
11 const [activeTag, setActiveTag] = useState<string | null>(null);
12 const strapiUrl = import.meta.env.PUBLIC_STRAPI_URL;
13
14 const visible = useMemo(() => {
15 if (!activeTag) return projects;
16 return projects.filter((p) => p.techTags.some((t) => t.slug === activeTag));
17 }, [activeTag, projects]);
18
19 return (
20 <div>
21 <div className="mb-8 flex flex-wrap gap-2">
22 <button
23 onClick={() => setActiveTag(null)}
24 className={`rounded-full px-3 py-1 text-sm ${!activeTag ? "bg-slate-900 text-white" : "bg-slate-200"}`}
25 >
26 All
27 </button>
28 {allTags.map((tag) => (
29 <button
30 key={tag.slug}
31 onClick={() => setActiveTag(tag.slug)}
32 className={`rounded-full px-3 py-1 text-sm ${activeTag === tag.slug ? "bg-slate-900 text-white" : "bg-slate-200"}`}
33 >
34 {tag.name}
35 </button>
36 ))}
37 </div>
38
39 <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3">
40 {visible.map((project) => (
41 <a
42 key={project.documentId}
43 href={`/projects/${project.slug}`}
44 className="block overflow-hidden rounded-lg border border-slate-200 bg-white transition hover:shadow-md"
45 >
46 {project.screenshots[0] && (
47 <img
48 src={`${strapiUrl}${project.screenshots[0].url}`}
49 alt={project.screenshots[0].alternativeText ?? project.name}
50 className="h-40 w-full object-cover"
51 />
52 )}
53 <div className="p-4">
54 <h2 className="text-lg font-semibold">{project.name}</h2>
55 {project.tagline && (
56 <p className="mt-1 text-sm text-slate-600">{project.tagline}</p>
57 )}
58 <div className="mt-3 flex flex-wrap gap-1">
59 {project.techTags.map((tag) => (
60 <span
61 key={tag.slug}
62 className="rounded-full px-2 py-0.5 text-xs"
63 style={{ backgroundColor: tag.color ?? "#e2e8f0" }}
64 >
65 {tag.name}
66 </span>
67 ))}
68 </div>
69 <div className="mt-3 text-sm text-slate-500">⭐ {project.stars}</div>
70 </div>
71 </a>
72 ))}
73 </div>
74 </div>
75 );
76}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.
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.
1// src/api/project/controllers/project.ts
2import { factories } from '@strapi/strapi';
3
4export default factories.createCoreController('api::project.project', ({ strapi }) => ({
5 async sync(ctx) {
6 const results = await strapi
7 .service('api::project.github-sync')
8 .syncAll();
9 const changed = results.filter((r) => r.changed);
10 ctx.body = {
11 total: results.length,
12 updated: changed.length,
13 };
14 },
15}));1// src/api/project/routes/custom-project.ts
2export default {
3 routes: [
4 {
5 method: 'POST',
6 path: '/projects/sync',
7 handler: 'project.sync',
8 config: {
9 auth: false,
10 },
11 },
12 ],
13};With the backend running, fire the sync with a single request:
1curl -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:
1npm run build
2npm 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.
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:
npx create-strapi-app@latest in your terminal and follow our Quick Start Guide to build your first Strapi project.