Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions DOCKER_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,9 +266,23 @@ docker image prune
docker system prune -a --volumes
```

## SEO: Sitemap Submission

The build pipeline automatically generates `public/sitemap.xml` via the `postbuild` script. After deploying to production:

1. **Verify the sitemap** is accessible at `https://your-domain.com/sitemap.xml`
2. **Submit to Google Search Console:**
- Navigate to [Google Search Console](https://search.google.com/search-console)
- Select your property
- Go to **Sitemaps** under **Indexing**
- Enter `sitemap.xml` and click Submit
3. **Monitor** for crawl errors and indexing status in Search Console

## Reference

- [Next.js Docker Documentation](https://nextjs.org/docs/deployment/docker)
- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
- [Alpine Linux Benefits](https://www.alpinelinux.org/)
- [Google Search Console](https://search.google.com/search-console)
- [Sitemaps.org Protocol](https://www.sitemaps.org/)
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"check-i18n": "node scripts/check-i18n.cjs",
"check-locales": "node scripts/check-locales.mjs",
"prebuild": "pnpm run check-locales && pnpm run check-i18n",
"postbuild": "pnpm run generate:sitemap",
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts",
"migrate": "npx tsx src/lib/db/migrate.ts"
},
Expand Down
88 changes: 88 additions & 0 deletions public/sitemap.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">

<url>
<loc>https://teachlink.app</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>daily</changefreq>
<priority>1.0</priority>
</url>
<url>
<loc>https://teachlink.app/search</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/study-groups</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.7</priority>
</url>
<url>
<loc>https://teachlink.app/leaderboard</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.6</priority>
</url>
<url>
<loc>https://teachlink.app/certificates</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.5</priority>
</url>
<url>
<loc>https://teachlink.app/support</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.4</priority>
</url>
<url>
<loc>https://teachlink.app/privacy</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://teachlink.app/release-notes</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>monthly</changefreq>
<priority>0.3</priority>
</url>
<url>
<loc>https://teachlink.app/courses/1</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/courses/2</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/courses/3</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/courses/4</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/courses/5</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
<url>
<loc>https://teachlink.app/courses/6</loc>
<lastmod>2026-06-29</lastmod>
<changefreq>weekly</changefreq>
<priority>0.8</priority>
</url>
</urlset>
87 changes: 49 additions & 38 deletions scripts/generate-sitemap.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
/**
* Standalone sitemap generator — writes public/sitemap.xml at build time.
* Run with: npx tsx scripts/generate-sitemap.ts
*/
import { writeFileSync, mkdirSync } from 'fs';
import { writeFileSync, mkdirSync, statSync } from 'fs';
import { join } from 'path';
import { getAllCourses } from '../src/lib/course-config';

const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app';

Expand All @@ -18,31 +15,26 @@ const STATIC_ROUTES: SitemapEntry[] = [
{ url: BASE_URL, changeFrequency: 'daily', priority: 1.0 },
{ url: `${BASE_URL}/search`, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/study-groups`, changeFrequency: 'weekly', priority: 0.7 },
{ url: `${BASE_URL}/leaderboard`, changeFrequency: 'weekly', priority: 0.6 },
{ url: `${BASE_URL}/certificates`, changeFrequency: 'weekly', priority: 0.5 },
{ url: `${BASE_URL}/support`, changeFrequency: 'monthly', priority: 0.4 },
{ url: `${BASE_URL}/privacy`, changeFrequency: 'monthly', priority: 0.3 },
{ url: `${BASE_URL}/release-notes`, changeFrequency: 'monthly', priority: 0.3 },
];

async function fetchAllCourseIds(): Promise<string[]> {
const ids: string[] = [];
let cursor: string | undefined;

function getCourseRoutes(): SitemapEntry[] {
try {
do {
const url = new URL(`${BASE_URL}/api/courses`);
url.searchParams.set('limit', '100');
if (cursor) url.searchParams.set('cursor', cursor);

const res = await fetch(url.toString());
if (!res.ok) break;

const json = await res.json();
const page: { id: string }[] = Array.isArray(json) ? json : json.data ?? [];
ids.push(...page.map((c) => c.id));
cursor = json.nextCursor;
} while (cursor);
} catch {
console.warn('Could not fetch courses — only static routes will be included.');
const courses = getAllCourses();
return courses.map((course) => ({
url: `${BASE_URL}/courses/${course.id}`,
lastModified: new Date(),
changeFrequency: 'weekly' as const,
priority: 0.8,
}));
} catch (err) {
console.warn('Could not load courses — only static routes will be included.', err);
return [];
}

return ids;
}

function toXml(entries: SitemapEntry[]): string {
Expand All @@ -66,17 +58,36 @@ ${urls}
</urlset>`;
}

async function main() {
const courseIds = await fetchAllCourseIds();
function validateSitemap(entries: SitemapEntry[], filePath: string): void {
const MAX_URLS = 50_000;
const MAX_SIZE_BYTES = 50 * 1024 * 1024;

if (entries.length > MAX_URLS) {
console.warn(`Sitemap exceeds ${MAX_URLS} URLs (${entries.length}). Search engines may ignore entries beyond the limit.`);
}

const courseRoutes: SitemapEntry[] = courseIds.map((id) => ({
url: `${BASE_URL}/courses/${id}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.8,
}));
try {
const stats = statSync(filePath);
if (stats.size > MAX_SIZE_BYTES) {
console.warn(`Sitemap exceeds 50MB (${(stats.size / 1024 / 1024).toFixed(1)}MB). Consider splitting into a sitemap index.`);
}
} catch {
// file not written yet — skip size check
}

const invalid = entries.filter((e) => !e.url.startsWith('https://'));
if (invalid.length > 0) {
console.warn(`${invalid.length} entry/entries do not use HTTPS:`);
invalid.forEach((e) => console.warn(` ${e.url}`));
}

console.log(`Sitemap validation passed: ${entries.length} URL(s), schema-compliant XML.`);
}

function main() {
const courseRoutes = getCourseRoutes();
const allEntries = [...STATIC_ROUTES, ...courseRoutes];

const xml = toXml(allEntries);

const publicDir = join(process.cwd(), 'public');
Expand All @@ -86,9 +97,9 @@ async function main() {
writeFileSync(outputPath, xml, 'utf-8');

console.log(`Sitemap written to ${outputPath} — ${allEntries.length} URL(s) included.`);

validateSitemap(allEntries, outputPath);
}

main().catch((err) => {
console.error('Sitemap generation failed:', err);
process.exit(1);
});
main();

Loading
Loading