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
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:
- Intercept the static HTML response
- Check for query parameters (
level,days) - 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.
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
All React components will be converted to Astro components following these patterns:
- Use existing
Layout.astrowrapper - Import
Seocomponent with base/default OG image and title/description - IMPORTANT: Do NOT check
Astro.url.searchParamsfor query params - middleware handles this - Render 4 section components + prefooter
- Static generation (no
prerender = false) - page is fully prerendered
- Reuse existing
main-page-header.astrocomponent - Use Astro
<Image>withdensities={[1, 2]}for retina support - Import
headerIllus.pngfrom assets
- Reuse existing
container-two-columns.astroandtext-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()
- Import 3 benefit images from
assets/benefits/ - Use Astro
<Image>withdensities={[1, 2]} - Use marketist
Textcomponent for typography - Responsive gap changes via CSS custom properties
- Reuse existing
section-header.astrocomponent - Use Astro
<Image>for levels.png - Center alignment with max-width constraint
- Similar structure to
home/prefooter.astrobut karma-specific - Use karma translations:
ctaTitle,ctaDesc - Link to
/downloadspage - Red gradient background
THIS IS WHERE ALL QUERY PARAM LOGIC LIVES - NOT IN THE ASTRO PAGE
The middleware will intercept the static HTML and rewrite meta tags based on query params.
Functions to implement:
-
shouldRewrite(req, res)- Condition checker- Check if response is HTML
- Check if path is
/karmaor starts with locale +/karma - Check if query params exist (
levelordays)
-
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
- For
-
getKarmaTitle(searchParams, t)- Get page title- Check for
daysparam (30, 60, 100) - Return
t('seo.streak.{days}.title')or defaultt('seo.title')
- Check for
-
getKarmaDescription(searchParams, t)- Get page description- Check for
daysparam (30, 60, 100) - Return
t('seo.streak.{days}.description')or defaultt('seo.description')
- Check for
-
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)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/'))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')/apps/todoist-astro/src/pages/karma.astro- Main page entry point (static, no query param logic)/apps/todoist-astro/src/components/karma/header.astro- Header section/apps/todoist-astro/src/components/karma/two-column-section.astro- Demo section with scene image/apps/todoist-astro/src/components/karma/three-column-section.astro- 3 feature boxes/apps/todoist-astro/src/components/karma/one-column-section.astro- Levels section/apps/todoist-astro/src/components/karma/prefooter.astro- CTA section/apps/todoist-astro/src/server/rewrite-karma-metadata.ts- Express middleware for metadata rewriting
/apps/todoist-astro/src/run-server.ts- Add karma metadata middleware registration
src/components/main-page-header.astro- For header title/subtitle/CTAsrc/components/container-two-columns.astro- Two-column layout wrappersrc/components/text-lock-up.astro- Title + description + buttons patternsrc/components/section-header.astro- Section titles with subtitlessrc/layouts/Layout.astro- Page layout wrappersrc/components/seo.astro- SEO meta tagssrc/components/nav/header.astro- Top navigation
@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
src/server/rewrite-celebration-states.ts- Reference for OG meta tag rewritingsrc/server/modify-response.ts-expressModifyResponseutility
- Run dev server:
npm run dev:astro - Visit:
http://localhost:4321/karma - Verify all sections render correctly
- Check responsive behavior (mobile, tablet, desktop)
- Test all 19 locales (at minimum: en, de, ja, es, fr)
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:
- View page source and inspect
og:imagemeta tag - Use social media debuggers (Facebook, Twitter, LinkedIn)
- Verify correct image dimensions (1200x628 or 1200x1200)
- Open browser DevTools Network tab
- Verify Astro generates 1x and 2x versions of images
- Check WebP format is used (automatic optimization)
- Verify scene images load from Cloudinary
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)
- Check heading hierarchy (h1 → h2)
- Verify all images have proper alt text (decorative images should have empty alt)
- Test keyboard navigation
- Verify color contrast ratios
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']()
}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).
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
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).
This checklist is organized to minimize context switching and create a reviewable PR (~800 LOC or less).
- 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)
-
- Create
/apps/todoist-astro/src/components/karma/header.astro- Import and use
main-page-header.astro - Import
header-illus.pngwith Astro Image - Use
densities={[1, 2]}for retina - Add responsive image wrapper styles
- Import and use
- 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
- Import and use
- 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
Textcomponent for typography - Map over features array with title/description
- Add responsive gap styles using CSS custom properties
- Import 3 benefit images from
- Create
/apps/todoist-astro/src/components/karma/one-column-section.astro- Import and use
section-header.astro - Import
levels.pngwith Astro Image - Center alignment with max-width
- Import and use
- 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.astrostyle)
- Use marketist
- Create
/apps/todoist-astro/src/pages/karma.astro- Import all 5 karma components
- Import
Layout.astroandSeo.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)
- Title:
- Render page structure: Header → 4 sections → Prefooter
- Add
.karma-pagewrapper styles (flex column, gap: var(--space-96)) - VERIFY: No
export const prerender = false - VERIFY: No
Astro.url.searchParamsusage
- Create
/apps/todoist-astro/src/server/rewrite-karma-metadata.ts- Import
expressModifyResponsefrom./modify-response.ts - Import
getTranslatorfrom@/i18n.config - Define karma level map (1-7 → level names)
- Implement
shouldRewrite(req, res)function- Check if HTML response
- Check if path matches
/karmaor/{locale}/karma - Check if
levelordaysquery params exist
- Implement
buildKarmaOgImageUrl(searchParams, locale, baseUrl)function- Map
level=1-7to static OG image paths - Return full URL to pre-generated OG image
- Return null if no valid params
- Map
- Implement
getKarmaTitle(searchParams, t)function- Check for
daysparam (30, 60, 100) - Return
t('seo.streak.{days}.title')if present - Return
t('seo.title')as default
- Check for
- Implement
getKarmaDescription(searchParams, t)function- Check for
daysparam (30, 60, 100) - Return
t('seo.streak.{days}.description')if present - Return
t('seo.description')as default
- Check for
- 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
rewriteKarmaMetadataconstant
- Import
- 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)
- Add import:
- Build verification
- Run
npm run dev:astro - Visit
http://localhost:4321/karma - Verify page renders without errors
- Check browser console for errors
- Run
- 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
- Test English:
- 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"
- Test
- Static generation
- Verify no
export const prerender = falsein karma.astro - Check that page builds as static HTML
- Run production build:
npm run build - Verify
dist/client/karma/index.htmlexists
- Verify no
- SEO validation
- Use Facebook Sharing Debugger
- Use Twitter Card Validator
- Verify OG images display correctly for all test URLs
- 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