The scariest moment is just before you leave the save room.

Regarding the implementation process of this blog site

#Development

This article was machine-translated. Read the original (日本語)

From Empty Repository to Completion – Implementation Flow (Phases 0-8)

We started by writing a plan and then divided it into phases. However, the actual implementation didn't exactly match the plan. To put it simply, the biggest difference between is that the plan had only 7 phases, with audio and video implementation left "for later". However, in reality, we added phase 8 and completed the audio and video support implementation.


Phase 0 – Building the Foundation

First, we built the foundation.

  • Created git init, a GitHub repository.
  • Launched pnpm workspaces + Turborepo, and created the skeleton forapps/web and apps/console (both Next.js 16 / App Router / TypeScript), packages/db and packages/content.
  • Configured the shared tsconfig / ESLint / Prettier, and turbo run build|dev|lint.
  • Created .env.example per application.

Phase 1 ── Database and Shared Packages

After the foundation, we moved on to the heart of the project.

  • packages/db: Define the schema for all tables in Drizzle, generate migrations, and apply them to Supabase. Storage bucket policies are also managed via migrations.

  • Seed Script: Insert several sample articles with translations, tags, images, and links.

  • packages/content: Markdown pipeline, shortcode registry, React rendering components (CSS Modules).

Here, the two pillars on which all later features will be built—"typed data layer" and "shared body rendering"—are established. Solidifying these two first made the later stages much easier.


Phase 2 ── Console: Authentication and CRUD Operations for Posts

We began building the article editor.

  • Supabase Auth (GitHub OAuth). Middleware blocks sessions whose email address does not match ADMIN_EMAIL. All console routes are protected.

  • Article List: Paging, status display in 5 languages ​​(Draft/Published/Not Created per each language), title search, tag filtering, logical deletion and restoration with confirmation.

console homepage
  • Article Editor: Base language selection, slug (automatically generated from title and editable), Markdown text area + Live Preview (uses the rendering from packages/content, so preview = published appearance). Each translation can be drafted/published independently in the language tab.
new post
editor area
  • Tag management (language-specific names), link management per article.
tags page

Phase 3 ── Console: Upload

In this phase we implemented the image upload feature.

  • Server-side upload route: Accepts multiple files, supports jpeg/png/gif/pdf.

  • Images are processed with sharp: Longest side limited to 2000px, EXIF ​​(after applying rotation) removed, thumbnails of approximately 400px are generated.

  • Files are decoded and verified (files with disguised extensions, such as .txt, are rejected).

  • File processing logic is defined by its MIME type ── This branching point later became the point for adding "audio/video" functionality.

  • Editor media panel: Drag and drop multiple uploads, thumbnail grid with progress, alt text, sorting, delete (also deletes the storage entity), "insert" button.

  • Cover image selection.

media upload

The choice to "sort logic by MIME type" in this phase created basis for Phase 8.


Phase 4 ── Console: Machine Translation

This is the core of the multilingual blog.

  • Wrap the Google Cloud Translation v3 client (direct REST access, service account authentication) in a server action.

  • Translation UI in the editor: Source is the base language, target is any combination of the remaining four languages ​​using checkboxes.

  • Translation flow: Mask shortcodes, codes, and URLs → Translate title, excerpt, and body → Unmask → Save with is_machine_translated = true / status = draft.

  • When manually editing machine translation, update edited_at timestamp while retaining the is_machine_translated flag. "Re-translate (overwrite edits)" requires confirmation.

  • Display character count per request (to be mindful of the free 500,000 character/month limit).

machine tranlation

Confirmed: Even when translating English articles containing code blocks, shortcodes, and links into Japanese and German, the syntax and shortcodes remained intact. This confirmed that the masking design works with actual APIs.


Phase 5 ── Public Site: Core

In this phase we have finally created the reader side.

  • next-intl settings: Locale is obtained from packages/db. The locale priority is defined by "URL locale priority → Accept-Language → English". Language switching rewrites the current path.

  • Layout (CSS Modules, mobile-first): Sidebar (avatar, language switch, tag list, drawer on smartphones), header (title, alternating quotes), footer.

  • Article list /{locale}: Only articles published in that language, with pagination.

  • Article page /{locale}/posts/{slug}: Full rendering in packages/content. Images (Supabase host and external URLs), {{gallery}} Embla carousel, timestamped YouTube videos, PDFs, language-specific links, machine translation badge + original link.

post page
  • Tag page /{locale}/tags/{slug}.
  • Rendering strategy is ISR. The console immediately reflects changes by hitting a re-verification endpoint with a secret key every time it's published/updated, with hourly re-verification as a fallback.
save room homepage

The shared rendering created in Phase 1 comes into play here. Since the console preview and the live site are rendered with the same code, there's no "it looked fine in the preview but broke in production" issue.


Phase 6 ── Live Site: SEO and Distribution

This is about building the unseen parts.

  • hreflang Alternative links (only for languages ​​actually published in that language) + x-default on every page.

  • Sitemap with embedded hreflang alternative links, robots.txt.

  • OGP/Twitter meta: Cover image, or if none, next/og a 1200x630 title card generated on the fly. To ensure Japanese and Cyrillic titles display correctly, a subset of Google Fonts is loaded to match the title.

  • Language-specific RSS feeds (/{locale}/feed.xml).

I(Claude) ran into a problem with Next.js's metadata behavior, where it "replaces" rather than "merges" top-level keys. Therefore, a small workaround remains in this phase: re-specifying language-specific RSS links on each page each time.


Phase 7 ── Operation: Deployment, Backup, Archiving

This is the transition from "creating" to "operating."

  • Two Vercel projects from the monorepo, environment variable settings.
  • Export: A console action (an escape hatch for migration) that outputs a ZIP file containing all tables as JSON and all files, and a dry-run restore script that rolls back by default.
  • Automatic Backup: Weekly backups using GitHub Actions (pg_dump), uploaded to a private bucket, retaining the most recent 8 generations.

Phase 8 ── Audio/Video (The "postponed" part in the initial plan)

  • Implement Cloudflare R2 (S3 compatible, zero download charges).

  • Issue signed PUT URLs and upload audio/video directly to R2 from the browser (large files do not go through the server). Playback time and resolution are read by the browser before upload.

  • Use {{audio}} / {{video}} shortcodes and a custom audio/video player (on top of the native <audio> / <video>).

  • Accept only formats that the browser can actually play (audio: MP3/M4A/AAC/WAV/OGG/FLAC, video: MP4 (H.264/AAC) or WebM only. iPhone's .mov/HEVC requires conversion).

video preview
  • Added support for including audio and video in exports.

And here, the Phase 3 design pays off. Because in Phase 3 we structured it so that "the asset kind is just text, details are in metadata (JSONB), and processing logic is defined by MIME," no database migrations were needed to add audio and video. All we had to do was add a new storage destination (R2), create one branch in the MIME branching, and register two renderings in the shortcode registry.

We had classified things as "postponed" in the planning stage, but we properly designed them so they could be added in the future before postponing them. Therefore, when we changed our minds and decided to "make it after all," we could implement it without breaking changes. Personally(according to Claude), this was the best decision we made in this project.