Building js2devlog — Monorepo, CI/CD, and AWS Deployment
From pnpm + Turborepo monorepo scaffolding to GitLab CI/CD with AI code review and AWS static deployment — the full infrastructure story behind js2devlog
The Question — "How Do You Ship a Space Playground?"
In early 2025, I set out to build an interactive website combining space science, physics simulations, and arcade games — all rendered with Canvas 2D and synthesized audio via Web Audio API. Not a static blog with embedded videos, but something you can touch and hear.
The scope was the problem. Games, simulators, astronomy data viewers, and a dev blog — the concerns were too diverse for a single Next.js app. I also wanted a separate playground for experiments. Starting as a monolith would inevitably lead to a painful split later.
So I started with a monorepo from day one.
Monorepo Design — pnpm + Turborepo
Why pnpm
Among npm, yarn, and pnpm, the choice was clear.
First, strict dependency isolation. npm's hoisting allows phantom dependencies — packages not listed in package.json that work by accident because they're hoisted from a sibling. In a monorepo, this becomes dangerous: app A installs a package, app B silently imports it, and updating one breaks the other. pnpm's content-addressable storage and symlink structure prevents this entirely.
Second, disk efficiency. Identical versions of the same package are stored once in a global store. When multiple apps share React 19 and Next.js 15, this saves hundreds of megabytes.
Third, workspace protocol. "@js2/common": "workspace:*" explicitly references internal packages — pointing to source locally and resolving to versions during publish.
# pnpm-workspace.yaml
packages:
- 'apps/*'
- 'packages/*'
These two lines define the entire monorepo boundary.
Workspace Structure
js2devlog/
├── apps/
│ ├── js2blog/ ← Main blog + games (Next.js 15)
│ └── js2playground/ ← Experiment playground
├── packages/
│ └── common/ ← Shared components/hooks
├── turbo.json
├── pnpm-workspace.yaml
└── package.json ← type: "module", private: true
packages/common exposes TypeScript source directly — no build step. Both main and exports point to ./src/index.ts, so the consuming app's bundler handles transpilation. This eliminates a build step and makes code changes reflect instantly during development.
// packages/common/package.json
{
"name": "@js2/common",
"main": "./src/index.ts",
"exports": { ".": "./src/index.ts" },
"peerDependencies": {
"react": "^19.0.0",
"next": "^15.0.0"
}
}
React and Next.js are declared as peerDependencies for a critical reason. If they were dependencies, @js2/common would bundle its own React instance, causing "Invalid hook call" errors. Peer dependencies ensure the consuming app's React is shared.
Turborepo — Build Orchestration
Turborepo was chosen for topological task execution and usable local caching without remote setup.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["out/**", ".next/**"]
},
"dev": {
"persistent": true,
"cache": false
},
"lint": {
"dependsOn": ["^build"]
}
}
}
"dependsOn": ["^build"] means "my dependency packages must build first." Since @js2/blog depends on @js2/common, a single turbo build automatically runs common → blog in order.
dev is persistent: true so the terminal-occupying dev server isn't killed by Turbo. cache: false because caching dev server results is meaningless.
Root scripts support per-app execution through filter patterns:
{
"dev": "turbo dev",
"dev:blog": "turbo dev --filter=@js2/blog",
"build:blog": "turbo build --filter=@js2/blog",
"check:blog": "pnpm lint:blog && pnpm type:blog && prettier --check \"apps/js2blog/**/*.{ts,tsx,css,json}\""
}
Next.js 15 — The Static Export Decision
output: 'export'
One of the biggest architectural decisions was choosing static export.
// next.config.ts
const isDev = process.env.NODE_ENV !== 'production';
const nextConfig: NextConfig = {
output: isDev ? undefined : 'export',
trailingSlash: !isDev,
};
SSR is powerful but requires a running server. This project's content is mostly client-side Canvas rendering and external API calls — there's little reason to generate HTML on the server. Static export means uploading HTML/JS/CSS files to object storage like S3, dramatically reducing infrastructure cost.
The trade-offs were real:
- No server APIs:
headers(),cookies()unavailable - All routes pre-generated:
generateStaticParams()required for dynamic routes - No API routes: External API CORS handled via dev-only
rewritesproxy
// Dev-only CORS proxy
async rewrites() {
return [
{
source: '/api/exoplanet',
destination: 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync',
},
];
},
Internationalization — next-intl vs Static Export
The trickiest compatibility issue. next-intl defaults to calling getLocale() in Server Components, which internally uses headers() — incompatible with output: 'export'.
The solution is receiving locale from [locale] dynamic segment params:
// ❌ Fails with static export
const locale = await getLocale();
const t = await getTranslations('namespace');
// ✅ Extract locale from params
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'namespace' });
This pattern must be applied consistently across every page component. Missing even one causes the entire build to fail with an indirect error: "Dynamic server usage: headers."
TypeScript Configuration
Path resolution in a monorepo needs careful setup.
// tsconfig.base.json (root)
{
"compilerOptions": {
"target": "ES2017",
"module": "esnext",
"moduleResolution": "bundler",
"strict": true,
"incremental": true
}
}
moduleResolution: "bundler" correctly resolves the exports field while maintaining bundler flexibility. The previous node mode ignored @js2/common's exports field and fell back to main.
Each app extends the root config with path aliases:
// apps/js2blog/tsconfig.json
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@/*": ["./src/*"],
"@js2/common": ["../../packages/common/src"],
"@js2/common/*": ["../../packages/common/src/*"]
}
}
}
Code Quality — Automated Verification Pipeline
ESLint Flat Config
Migrating to ESLint 9's flat config consolidated configuration into a single file.
// eslint.config.mjs
export default [
{
plugins: {
'simple-import-sort': simpleImportSort,
'unused-imports': unusedImports,
'react-hooks': reactHooks,
prettier: prettier,
},
rules: {
'simple-import-sort/imports': 'error',
'unused-imports/no-unused-imports': 'error',
'react-hooks/rules-of-hooks': 'error',
'react-hooks/exhaustive-deps': 'warn',
},
},
];
simple-import-sort auto-sorts imports to reduce noise in diffs. unused-imports treats dead imports as errors to manage bundle size.
Husky + lint-staged
A commit-time gate that validates only changed files.
// .lintstagedrc.js
export default {
'apps/**/*.{ts,tsx}': () => ['turbo test:source'],
'packages/**/*.{ts,tsx}': () => ['turbo build'],
'{apps,packages}/**/*.{js,ts,tsx,json,css}': 'prettier --write',
};
App code changes trigger test:source (type check + lint + prettier). Shared package changes trigger a full build — changes that affect downstream consumers deserve stricter verification.
Including type checking (tsc --noEmit) in test:source means commits take 3+ minutes. But allowing type errors into the repository — even if CI catches them — can block other workflows. For a personal project, the full check stays. For a larger team, moving type checks to CI would be more practical.
GitLab CI/CD — Four-Stage Pipeline
Pipeline Architecture
stages:
- review # AI code review
- prepare # Label detection
- build # Application build
- deploy # AWS deployment
Stage 1: AI Code Review
Every MR automatically triggers an AI code review. The script extracts the Git diff, sends it to GPT-4o, and posts the review as an MR comment.
ai_review:
stage: review
script:
- node scripts/ai_review.js
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
The review analyzes code along three axes:
- Major issues (up to 5): Bug potential, security vulnerabilities, performance regressions — with file paths, line numbers, existing code, and suggested fixes
- Minor suggestions (up to 5): Style improvements, naming, simplification opportunities
- Overall assessment: ✅ Good / ⚠️ Needs improvement / 🔴 Attention required
Review tone is configurable via REVIEW_STYLE: friendly, performance_focused, security_focused, or maintainability_focused.
Real catches include: a requestAnimationFrame cleanup leak in Canvas code, and an useEffect dependency array containing an object reference that would cause infinite re-renders.
The limitations are also clear. Domain-specific issues like physics simulation accuracy or subtle Canvas coordinate offset bugs are often missed. Still, it's an effective safety net for "obvious but easy-to-miss" mistakes.
Stage 2: Label-Based Conditional Build
In a monorepo, building every app on every commit is wasteful. MR labels (js2blog, js2playground) specify which app changed, and only that app is built.
build:js2blog:
script:
- |
if ! echo "${DEPLOY_LABELS}" | grep -q "js2blog"; then
echo "No js2blog label - skipping build"
exit 0
fi
- pnpm --filter @js2/blog build
artifacts:
paths:
- apps/js2blog/out/
Build artifacts (out/) are stored as GitLab artifacts for the deploy stage. The prebuild hook auto-generates sitemap.xml and robots.txt.
AWS Deployment — S3 + CloudFront
Static Site Hosting Architecture
GitLab CI → S3 Bucket → CloudFront CDN → Users
Static files from Next.js output: 'export' are uploaded to S3 and served globally via CloudFront CDN.
Caching Strategy — The Most Deliberated Decision
Caching strategy directly impacts user experience. Get it wrong and users load stale JS that breaks the page, or download everything fresh every visit.
Next.js's _next/static/ folder contains JS/CSS chunks with content hashes in filenames. The filename itself is the version, so permanent caching is safe.
HTML files keep the same path (/index.html) while content changes — they must never be cached.
This difference is reflected in a two-phase deployment:
deploy:js2blog:
script:
# Phase 1: JS/CSS chunks — permanent cache
- aws s3 sync apps/js2blog/out/_next/static/
s3://$BUCKET/js2blog/_next/static/
--cache-control "public, max-age=31536000, immutable"
# Phase 2: HTML + rest — no cache, delete stale files
- aws s3 sync apps/js2blog/out/
s3://$BUCKET/js2blog/
--exclude "_next/*"
--delete
--cache-control "no-cache, no-store, must-revalidate"
# Phase 3: CloudFront cache invalidation
- aws cloudfront create-invalidation
--distribution-id $CF_DIST_ID
--paths "/*.html" "/index.html" "/"
Why two phases? Deployment isn't atomic. If HTML uploads first and JS follows later, users in the gap get new HTML referencing new JS chunks that don't exist yet — resulting in 503 errors. The reverse order has similar risks.
Uploading JS/CSS first (with max-age=31536000), then HTML (with no-cache) minimizes the timing gap. Previous deployment's JS chunks remain in S3, so users with cached old HTML still work fine. The --delete flag only applies in the HTML phase — cleaning stale HTML while preserving JS chunks.
CloudFront Invalidation
CloudFront edge caches can hold HTML for up to 24 hours. create-invalidation forces refresh of HTML paths immediately after deployment. _next/static/* technically doesn't need invalidation since filenames contain hashes, but it's included as a safety measure.
External API Proxy — Solving CORS via CloudFront
This project uses several external APIs: NASA APOD, Exoplanet Archive, and Mars rover RSS feeds. The problem is that direct browser requests to these APIs trigger CORS errors.
In development, Next.js rewrites provide a proxy:
// next.config.ts — dev only
async rewrites() {
return [
{
source: '/api/exoplanet',
destination: 'https://exoplanetarchive.ipac.caltech.edu/TAP/sync',
},
{
source: '/api/mars-rss',
destination: 'https://mars.nasa.gov/rss/api/',
},
];
},
But with output: 'export', these rewrites don't work in production — there's no server to intercept requests.
The production solution uses CloudFront multi-origin configuration. External API servers are registered as additional origins alongside S3, with path patterns (/api/exoplanet, /api/mars-rss) routing to the appropriate origin. Client code uses the same relative paths in both environments.
CloudFront Distribution
├─ Default Origin: S3 (static files)
├─ /api/exoplanet → Origin: exoplanetarchive.ipac.caltech.edu
└─ /api/mars-rss → Origin: mars.nasa.gov
A key benefit is that API keys never reach the client. The NASA APOD API key is injected by a CloudFront Function at the edge, so it never appears in the browser's network tab.
Security Configuration — Defense in Depth
A static site still needs security. Multiple defense layers are configured across CloudFront and S3.
CloudFront response header policies automatically attach security headers to every response:
| Header | Value | Purpose |
|---|---|---|
X-Content-Type-Options | nosniff | Prevent MIME sniffing |
X-Frame-Options | DENY | Prevent clickjacking |
X-XSS-Protection | 1; mode=block | Enable XSS filter |
Strict-Transport-Security | max-age=31536000; includeSubDomains | Enforce HTTPS |
Referrer-Policy | strict-origin-when-cross-origin | Limit referrer info |
Content-Security-Policy | default-src 'self'; ... | Restrict resource origins |
CSP was the trickiest. This site uses NASA APIs, external CDN fonts, and inline scripts (required by Next.js) — too strict breaks functionality, too loose is meaningless.
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline' cdn.jsdelivr.net;
img-src 'self' https: data:;
connect-src 'self' https://api.nasa.gov;
font-src 'self' cdn.jsdelivr.net;
img-src https: is broad because NASA APOD returns different external image URLs daily — there's no fixed whitelist. data: is needed for Canvas toDataURL() results.
S3 bucket policy restricts access to CloudFront OAC (Origin Access Control) only. Direct S3 URL access is blocked — content is only served through CloudFront.
CloudFront Functions — Edge Computing
CloudFront Functions are lighter and faster than Lambda@Edge. Two functions were written:
First, URL rewrite. Static export means /ko/about must resolve to /ko/about/index.html. S3 doesn't auto-resolve directory indices, so a viewer-request function transforms the URI:
function handler(event) {
var request = event.request;
var uri = request.uri;
// /ko/about → /ko/about/index.html
if (uri.endsWith('/')) {
request.uri += 'index.html';
} else if (!uri.includes('.')) {
request.uri += '/index.html';
}
return request;
}
Second, API key injection. For NASA API calls, the API key is added to the query string in an origin-request function. The key is stored in CloudFront Functions' KeyValueStore — never hardcoded.
Building these edge functions drove home an insight: "serverless" doesn't mean "no server" — it means "no server management." CloudFront Functions, S3 bucket policies, response header policies — all of it is server-side logic that needs to be configured.
Internationalization — ko/en Dual Service
next-intl + Static Export
The site supports Korean and English. The core challenge was making next-intl work with output: 'export'.
Routing is URL-based with locale prefixes on every page:
/ko/starship ← Korean Starship Mission
/en/starship ← English Starship Mission
/ko/devlog/... ← Korean dev blog
/en/devlog/... ← English dev blog
// src/i18n/routing.ts
export const routing = defineRouting({
locales: ['ko', 'en'] as const,
defaultLocale: 'ko',
});
Middleware — Redirect When Locale Is Missing
When a user visits /about without a locale prefix, they're redirected based on a cookie (NEXT_LOCALE). No cookie defaults to Korean (/ko/about).
// src/middleware.ts
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Already has locale prefix — pass through
const matchedLocale = LOCALES.find(loc => pathname === `/${loc}` || pathname.startsWith(`/${loc}/`));
if (matchedLocale) {
const response = NextResponse.next();
response.headers.set('x-next-intl-locale', matchedLocale);
return response;
}
// No locale — redirect based on cookie or default
const locale = getLocaleFromRequest(req);
return NextResponse.redirect(new URL(`/${locale}${pathname}`, req.url));
}
Translation on Both Server and Client
Server and client components use different patterns:
// Server Component (page.tsx) — getTranslations
const { locale } = await params;
const t = await getTranslations({ locale, namespace: 'about' });
const title = t('meta.title');
// Client Component — useTranslations
('use client');
const t = useTranslations('gameShare');
const text = t('shareText', { game: 'Asteroids', score: '12400' });
// → "🚀 Asteroids에서 12400 달성!" (ko)
// → "🚀 12400 on Asteroids!" (en)
The [locale] layout wraps children in NextIntlClientProvider, injecting messages so client components just call useTranslations without explicitly passing locale.
Message Files — 30 Namespaces
Translations live in messages/ko.json (98KB) and messages/en.json (89KB), organized into 30 namespaces covering UI text, screen reader aria-labels, game share text, and SEO metadata.
{
"asteroids": {
"ui": {
"hudAriaLabel": "Game status display",
"srStageAndLives": "Stage {stage}, {lives} lives remaining"
}
}
}
Translating accessibility text is extra work, but both Korean and English users deserve the same screen reader experience.
Static Build — Pre-generating All Combinations
generateStaticParams() generates every locale × route combination at build time:
// app/[locale]/devlog/[slug]/page.tsx
export async function generateStaticParams() {
const params = [];
for (const locale of routing.locales) {
const posts = getAllPosts(locale);
for (const post of posts) {
params.push({ locale, slug: post.slug });
}
}
return params;
// → [{ locale:'ko', slug:'starship-mission' },
// { locale:'en', slug:'starship-mission' }, ...]
}
18 posts × 2 languages = 36 HTML files. Including 25 main routes, over 86 static pages are generated. Each language variant has correct canonical URLs and hreflang alternate links for proper search engine indexing.
Locale-Aware Navigation
Instead of next/link, the project uses Link from @/i18n/navigation. This wrapper automatically maintains the current locale, so developers don't need to construct /${locale}/path manually:
// next/link — manual locale management
<Link href={`/${locale}/about`}>About</Link>
// @/i18n/navigation — automatic locale handling
<Link href="/about">About</Link>
Language switching preserves the current path while changing only the locale. usePathname() returns the pure path without locale (/about), enabling a clean router.replace(pathname, { locale: nextLocale }) call.
Developer Experience — Small Tools That Matter
Prebuild SEO Generation
A script auto-generates sitemap.xml and robots.txt before each build.
// scripts/generate-seo.mjs
const LOCALES = ['ko', 'en'];
const ROUTES = [
{ path: '/', priority: '1.0' },
{ path: '/starship', priority: '0.8' },
// ... 25 routes
];
Static export can't use Next.js's sitemap.ts, so this script generates them directly — including hreflang alternate links for proper multilingual indexing.
CSS Modules Over Tailwind
Not using Tailwind was deliberate. In a Canvas-heavy interactive project, CSS plays a smaller role overall, and game overlays and HUDs need precise positioning and complex animations. Explicit CSS files with variables and keyframes fit this project better than utility class composition.
The key advantage of CSS Modules is zero name collisions by design. Over 20 games and simulators each use common class names like .overlay, .title, .button — without any interference.
Dev Blog (Devlog) System
The main challenge was processing MDX at build time with minimal external dependencies.
Initially I used gray-matter for YAML frontmatter parsing, but it conflicted with Turbopack. gray-matter depends on js-yaml@3.x, which doesn't fully support ESM, causing bundling errors.
I wrote a custom parseFrontmatter function instead. It only supports simple key-value pairs and arrays — but that's all this project's frontmatter needs. One fewer dependency and a Turbopack compatibility fix in one move.
Looking Back
The most time-consuming part of building this project wasn't the flashy physics simulations or Canvas rendering — it was these "invisible infrastructure" decisions.
Choosing a monorepo meant deciding where package boundaries should go. Choosing static export meant compiling a list of things to give up. Each CI pipeline stage went through repeated "is this actually necessary?" reviews.
The S3 caching strategy in particular only became clear after I experienced the "new HTML + old JS" combination breaking in production. The correct design emerged only after seeing it fail. CloudFront security headers and CSP followed the same pattern — I initially postponed them thinking "it's a static site, security can wait," until discovering the NASA API key exposure issue forced proper edge function and response header policy configuration.
The internationalization system was also harder than expected. next-intl compatibility with static export, middleware redirects, managing 30 namespaces of messages, SEO hreflang mapping — it wasn't just "translate text" but closer to "operating two complete sites from one codebase."
Whether all these decisions were right remains to be seen. But I tried to make choices I could explain the reasoning behind — and recording that process is the purpose of this post.