Skip to content

Instantly share code, notes, and snippets.

@JefCurtis
Created February 10, 2026 20:03
Show Gist options
  • Select an option

  • Save JefCurtis/ea263eb999dcb3c64600c7c38365e99a to your computer and use it in GitHub Desktop.

Select an option

Save JefCurtis/ea263eb999dcb3c64600c7c38365e99a to your computer and use it in GitHub Desktop.
Karma Page Migration Plan: Next.js → Astro

Karma Page Migration Plan: Next.js → Astro

Context

The karma page currently exists in the Next.js todoist-landing-pages app and needs to be migrated to the Astro todoist-astro app. This migration is part of the broader effort to consolidate all marketing pages into the Astro codebase for better performance, simpler deployment, and improved developer experience.

The karma page showcases Todoist's productivity tracking feature with:

  • Dynamic OG images based on karma level (1-7) and streak days (7, 30, 60, 100)
  • Localized content across 19 languages
  • Product UI integration for scene images
  • Four content sections plus a prefooter CTA

Key Architectural Decision: Static Generation + Express Middleware

CRITICAL: The karma page will be fully prerendered as static HTML at build time. There will be NO export const prerender = false in the Astro page.

All dynamic behavior (OG images, titles, descriptions based on query params) will be handled by Express middleware in run-server.ts. The middleware will:

  1. Intercept the static HTML response
  2. Check for query parameters (level, days)
  3. Rewrite meta tags (og:image, og:title, og:description) before sending to client

This approach:

  • ✅ Keeps the page static for CDN caching and performance
  • ✅ Allows dynamic OG metadata based on query params
  • ✅ Follows the proven pattern from rewrite-celebration-states.ts

The Astro page code will NOT check query parameters. All query param logic lives in Express middleware only.

Implementation Approach

1. Asset Migration

Copy all karma images from Next.js to Astro:

Source: /apps/todoist-landing-pages/public/static/karma/
Target: /apps/todoist-astro/src/components/karma/assets/

Directory structure:

src/components/karma/assets/
├── header-illus.png
├── levels.png
├── benefits/
│   ├── daily-goals.png
│   ├── features-2.png
│   └── visualization-2.png
└── localized-images/
    ├── de/todoist-karma.png
    ├── en/todoist-karma.png
    ├── es/todoist-karma.png
    ├── fr/todoist-karma.png
    ├── ja/todoist-karma.png
    ├── pt-BR/todoist-karma.png
    └── ru/todoist-karma.png

Note: Only 7 locales have localized images. Other locales will use English as fallback.

Copy pre-generated OG images to public:

Source: /apps/todoist-landing-pages/public/static/ogimages/en/og-image-karma-*.png
Target: /apps/todoist-astro/public/static/ogimages/en/

Files: og-image-todoist-karma.png, og-image-karma-novice.png, og-image-karma-intermediate.png, og-image-karma-professional.png, og-image-karma-expert.png, og-image-karma-master.png, og-image-karma-grandmaster.png, og-image-karma-enlightened.png

2. Component Migration

All React components will be converted to Astro components following these patterns:

Main Page: src/pages/karma.astro

  • Use existing Layout.astro wrapper
  • Import Seo component with base/default OG image and title/description
  • IMPORTANT: Do NOT check Astro.url.searchParams for query params - middleware handles this
  • Render 4 section components + prefooter
  • Static generation (no prerender = false) - page is fully prerendered

Header: src/components/karma/header.astro

  • Reuse existing main-page-header.astro component
  • Use Astro <Image> with densities={[1, 2]} for retina support
  • Import headerIllus.png from assets

Two Column Section: src/components/karma/two-column-section.astro

  • Reuse existing container-two-columns.astro and text-lock-up.astro
  • Use getSceneImage() from @doist/product-ui/get-scene-image (returns Cloudinary URL)
  • Render scene as <img> with Cloudinary URL (not Astro Image)
  • Link to help center with getRelativeLocaleUrl()

Three Column Section: src/components/karma/three-column-section.astro

  • Import 3 benefit images from assets/benefits/
  • Use Astro <Image> with densities={[1, 2]}
  • Use marketist Text component for typography
  • Responsive gap changes via CSS custom properties

One Column Section: src/components/karma/one-column-section.astro

  • Reuse existing section-header.astro component
  • Use Astro <Image> for levels.png
  • Center alignment with max-width constraint

Prefooter: src/components/karma/prefooter.astro

  • Similar structure to home/prefooter.astro but karma-specific
  • Use karma translations: ctaTitle, ctaDesc
  • Link to /downloads page
  • Red gradient background

3. Express Middleware for Dynamic Metadata

THIS IS WHERE ALL QUERY PARAM LOGIC LIVES - NOT IN THE ASTRO PAGE

Express Middleware: src/server/rewrite-karma-metadata.ts

The middleware will intercept the static HTML and rewrite meta tags based on query params.

Functions to implement:

  1. shouldRewrite(req, res) - Condition checker

    • Check if response is HTML
    • Check if path is /karma or starts with locale + /karma
    • Check if query params exist (level or days)
  2. buildKarmaOgImageUrl(searchParams, locale, baseUrl) - Build OG image URL

    • For level=1-7: Map to karma level name and return static image path
      • Maps: 1→novice, 2→intermediate, 3→professional, 4→expert, 5→master, 6→grandmaster, 7→enlightened
      • Example: ${baseUrl}/static/ogimages/en/og-image-karma-novice.png
    • Return null if no valid params
  3. getKarmaTitle(searchParams, t) - Get page title

    • Check for days param (30, 60, 100)
    • Return t('seo.streak.{days}.title') or default t('seo.title')
  4. getKarmaDescription(searchParams, t) - Get page description

    • Check for days param (30, 60, 100)
    • Return t('seo.streak.{days}.description') or default t('seo.description')
  5. modifyHtml(req, res, buffer) - HTML modification

    • Extract query params and locale
    • Build karma OG image URL
    • Get karma title and description (requires loading translations for locale)
    • Use regex to replace:
      • <meta property="og:image" content="...">
      • <meta property="og:title" content="...">
      • <meta property="og:description" content="...">
      • <title>...</title>
      • <meta name="description" content="...">
    • Update OG image metadata (type, width, height)

Export:

export const rewriteKarmaMetadata = expressModifyResponse(shouldRewrite, modifyHtml)

Server Integration: src/run-server.ts

Add middleware BEFORE static file serving:

import { rewriteKarmaMetadata } from './server/rewrite-karma-metadata.ts'

app.use(rewriteCelebrationStates)
app.use(rewriteKarmaMetadata)  // Add here
app.use('/', serveStaticWithSend('dist/client/'))

4. Translations

Already migrated! All 19 karma.json files exist in /apps/todoist-astro/src/content/i18n/{locale}/karma.json

In Astro components:

const t = await Astro.locals.getTranslations('karma')

In Express middleware:

import { getTranslator } from '@/i18n.config'
const t = await getTranslator(locale)('karma')

Critical Files

Files to Create

  1. /apps/todoist-astro/src/pages/karma.astro - Main page entry point (static, no query param logic)
  2. /apps/todoist-astro/src/components/karma/header.astro - Header section
  3. /apps/todoist-astro/src/components/karma/two-column-section.astro - Demo section with scene image
  4. /apps/todoist-astro/src/components/karma/three-column-section.astro - 3 feature boxes
  5. /apps/todoist-astro/src/components/karma/one-column-section.astro - Levels section
  6. /apps/todoist-astro/src/components/karma/prefooter.astro - CTA section
  7. /apps/todoist-astro/src/server/rewrite-karma-metadata.ts - Express middleware for metadata rewriting

Files to Modify

  1. /apps/todoist-astro/src/run-server.ts - Add karma metadata middleware registration

Existing Components to Reuse

  • src/components/main-page-header.astro - For header title/subtitle/CTA
  • src/components/container-two-columns.astro - Two-column layout wrapper
  • src/components/text-lock-up.astro - Title + description + buttons pattern
  • src/components/section-header.astro - Section titles with subtitles
  • src/layouts/Layout.astro - Page layout wrapper
  • src/components/seo.astro - SEO meta tags
  • src/components/nav/header.astro - Top navigation

Existing Services to Reuse

  • @doist/product-ui/get-scene-image - Scene image Cloudinary URLs (already works in Astro)
  • @/services/internationalization - getLocale(), getRelativeLocaleUrl()
  • @/services/og-image - getOgImage() helper for OG URLs

Existing Middleware Patterns to Follow

  • src/server/rewrite-celebration-states.ts - Reference for OG meta tag rewriting
  • src/server/modify-response.ts - expressModifyResponse utility

Verification

Build and Dev Testing

  1. Run dev server: npm run dev:astro
  2. Visit: http://localhost:4321/karma
  3. Verify all sections render correctly
  4. Check responsive behavior (mobile, tablet, desktop)
  5. Test all 19 locales (at minimum: en, de, ja, es, fr)

OG Image Testing

Test these URLs:

  • /karma - Base OG image
  • /karma?level=1 - Novice
  • /karma?level=5 - Master
  • /karma?level=7 - Enlightened
  • /karma?days=30 - 30-day streak (if implementing dynamic generation)

Validate with:

  1. View page source and inspect og:image meta tag
  2. Use social media debuggers (Facebook, Twitter, LinkedIn)
  3. Verify correct image dimensions (1200x628 or 1200x1200)

Image Optimization Testing

  1. Open browser DevTools Network tab
  2. Verify Astro generates 1x and 2x versions of images
  3. Check WebP format is used (automatic optimization)
  4. Verify scene images load from Cloudinary

Cross-locale Testing

Test with different locales:

  • /karma (English)
  • /de/karma (German)
  • /ja/karma (Japanese)
  • /es/karma (Spanish)

Verify:

  • Translations load correctly
  • Scene images use correct locale
  • Help center links include locale prefix
  • Localized hero images load (or fallback to English)

Accessibility

  1. Check heading hierarchy (h1 → h2)
  2. Verify all images have proper alt text (decorative images should have empty alt)
  3. Test keyboard navigation
  4. Verify color contrast ratios

Known Challenges

1. Localized Hero Images

Challenge: Only 7/19 locales have localized hero images

Solution: Use import.meta.glob() with try/catch fallback to English:

const images = import.meta.glob<{ default: ImageMetadata }>(
    './assets/localized-images/**/*.png',
    { eager: false }
)

let heroImage
try {
    heroImage = await images[`./assets/localized-images/${locale}/todoist-karma.png`]()
} catch {
    heroImage = await images['./assets/localized-images/en/todoist-karma.png']()
}

2. Scene Image Integration

Challenge: Verify getSceneImage() from product-ui works in Astro server components

Solution: The function is synchronous and returns a Cloudinary URL string, so it will work. Just use standard <img> tag with the returned URL (not Astro Image component since it's an external URL).

3. CSS Variable Usage

Challenge: Maintain exact spacing and responsive behavior from Next.js version

Solution:

  • Use CSS custom properties for responsive gaps: --gap: var(--space-96)
  • Use media queries with CSS variables: @media (--screen-lt-md)
  • Follow existing Astro component patterns from similar pages

4. Pre-generated OG Images

Challenge: Verify all 8 karma OG images exist in Next.js before copying

Mitigation: Check source directory first. If any are missing, they'll need to be generated (likely Figma exports or design team assets).

Implementation Checklist (PR-Scoped)

This checklist is organized to minimize context switching and create a reviewable PR (~800 LOC or less).

Phase 1: Asset Setup (~50 LOC)

  • Copy karma images from Next.js to /apps/todoist-astro/src/components/karma/assets/
    • header-illus.png
    • levels.png
    • benefits/ directory (3 images)
    • localized-images/ directory (7 locale subdirectories)
  • Copy pre-generated OG images from Next.js to /apps/todoist-astro/public/static/ogimages/en/
    • og-image-todoist-karma.png (base)
    • og-image-karma-{level}.png (7 images for each level)

Phase 2: Astro Components (~400 LOC)

  • Create /apps/todoist-astro/src/components/karma/header.astro
    • Import and use main-page-header.astro
    • Import header-illus.png with Astro Image
    • Use densities={[1, 2]} for retina
    • Add responsive image wrapper styles
  • Create /apps/todoist-astro/src/components/karma/two-column-section.astro
    • Import and use container-two-columns.astro, text-lock-up.astro
    • Call getSceneImage('Karma', locale, 'red') for Cloudinary URL
    • Render as <img> (not Astro Image - external URL)
    • Use getRelativeLocaleUrl() for help center link
    • Add responsive column styles
  • Create /apps/todoist-astro/src/components/karma/three-column-section.astro
    • Import 3 benefit images from assets/benefits/
    • Use Astro Image with densities={[1, 2]}
    • Use marketist Text component for typography
    • Map over features array with title/description
    • Add responsive gap styles using CSS custom properties
  • Create /apps/todoist-astro/src/components/karma/one-column-section.astro
    • Import and use section-header.astro
    • Import levels.png with Astro Image
    • Center alignment with max-width
  • Create /apps/todoist-astro/src/components/karma/prefooter.astro
    • Use marketist Container, Heading, Text, Button
    • Load karma translations: ctaTitle, ctaDesc
    • Link to /downloads
    • Red gradient background (match home/prefooter.astro style)

Phase 3: Main Page (~50 LOC)

  • Create /apps/todoist-astro/src/pages/karma.astro
    • Import all 5 karma components
    • Import Layout.astro and Seo.astro
    • Get locale with getLocale(Astro.currentLocale)
    • Load karma translations with await Astro.locals.getTranslations('karma')
    • Use default SEO values (no query param checking)
      • Title: t('seo.title')
      • Description: t('seo.description')
      • OG image: getOgImage('og-image-todoist-karma.png', Astro.site, locale)
    • Render page structure: Header → 4 sections → Prefooter
    • Add .karma-page wrapper styles (flex column, gap: var(--space-96))
    • VERIFY: No export const prerender = false
    • VERIFY: No Astro.url.searchParams usage

Phase 4: Express Middleware (~250 LOC)

  • Create /apps/todoist-astro/src/server/rewrite-karma-metadata.ts
    • Import expressModifyResponse from ./modify-response.ts
    • Import getTranslator from @/i18n.config
    • Define karma level map (1-7 → level names)
    • Implement shouldRewrite(req, res) function
      • Check if HTML response
      • Check if path matches /karma or /{locale}/karma
      • Check if level or days query params exist
    • Implement buildKarmaOgImageUrl(searchParams, locale, baseUrl) function
      • Map level=1-7 to static OG image paths
      • Return full URL to pre-generated OG image
      • Return null if no valid params
    • Implement getKarmaTitle(searchParams, t) function
      • Check for days param (30, 60, 100)
      • Return t('seo.streak.{days}.title') if present
      • Return t('seo.title') as default
    • Implement getKarmaDescription(searchParams, t) function
      • Check for days param (30, 60, 100)
      • Return t('seo.streak.{days}.description') if present
      • Return t('seo.description') as default
    • Implement modifyHtml(req, res, buffer) async function
      • Convert buffer to string
      • Extract query params from req.query
      • Get locale from req.cookies.NEXT_LOCALE (default 'en')
      • Get base URL from request
      • Load translations: const t = await getTranslator(locale)('karma')
      • Build OG image URL
      • Get title and description
      • Replace meta tags using regex:
        • <meta property="og:image" content="...">
        • <meta property="og:title" content="...">
        • <meta property="og:description" content="...">
        • <title>...</title>
        • <meta name="description" content="...">
      • Update OG image type/width/height
      • Return modified HTML
    • Export rewriteKarmaMetadata constant

Phase 5: Server Integration (~5 LOC)

  • Edit /apps/todoist-astro/src/run-server.ts
    • Add import: import { rewriteKarmaMetadata } from './server/rewrite-karma-metadata.ts'
    • Add middleware call BEFORE serveStaticWithSend(): app.use(rewriteKarmaMetadata)

Phase 6: Testing & Verification

  • Build verification
    • Run npm run dev:astro
    • Visit http://localhost:4321/karma
    • Verify page renders without errors
    • Check browser console for errors
  • Component rendering
    • All 5 sections display correctly
    • Images load and are optimized (check Network tab for 1x/2x versions)
    • Scene image loads from Cloudinary
    • Responsive layout works (mobile, tablet, desktop)
  • Translations
    • Test English: /karma
    • Test German: /de/karma
    • Test Japanese: /ja/karma
    • Verify all text displays correctly
  • Dynamic metadata (query params)
    • Test /karma?level=1 - View source, verify OG image is "novice"
    • Test /karma?level=5 - View source, verify OG image is "master"
    • Test /karma?level=7 - View source, verify OG image is "enlightened"
    • Test /karma?days=30 - View source, verify title is "30 days of staying on track"
    • Test /karma?days=100 - View source, verify title is "100 days in a row"
  • Static generation
    • Verify no export const prerender = false in karma.astro
    • Check that page builds as static HTML
    • Run production build: npm run build
    • Verify dist/client/karma/index.html exists
  • SEO validation
    • Use Facebook Sharing Debugger
    • Use Twitter Card Validator
    • Verify OG images display correctly for all test URLs

Success Criteria

  • Karma page renders identically to Next.js version
  • All images optimized with Astro Image (1x and 2x densities)
  • Dynamic OG images work for karma levels via Express middleware
  • Dynamic titles work for streak milestones via Express middleware
  • All 19 locales display correctly
  • Scene images load from product-ui/Cloudinary
  • Responsive layout works on all screen sizes
  • Help center links include locale prefix
  • SEO metadata matches Next.js version (or better)
  • Page is 100% statically generated (confirmed: no prerender = false)
  • Express middleware correctly rewrites meta tags based on query params
  • Astro page contains ZERO query param logic
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment