Realistic Icon
Developer guide

Build, ship, and extend the 3D Icon Gallery.

Everything an engineer needs to take this Astro + React showcase from a polished UI demo to a production web app — stack reference, sitemap, suggested database schema, REST API contract, deployment notes, and a meta-prompt to regenerate the project from scratch.

1. Overview

Purpose & target users

The 3D Icon Gallery is a showcase that demonstrates a complete product flow around a curated library of premium 3D icons: public catalog browsing, AI generation in Studio, a member dashboard, profile / billing surfaces, and a sign-in flow. The target user is a designer or product team looking to license, generate, or remix 3D icons for marketing, decks, landing pages, and product UI.

High-level architecture

High-level architecture
High-level architecture

Module map

  • src/tokens/ — color, spacing, type, glass, motion tokens (CSS vars).
  • src/primitives/ — 60+ low-level React components (Button, Input, IconButton, Surface, etc.).
  • src/components/ — app shell, layout, modals, theme + provider context.
  • src/patterns/ — composed marketing/product patterns (RealisticIconHero, RealisticIconNavbar).
  • src/showcases/3d-icon-gallery/ — this product's full surface: gallery, detail, sign-in, member dashboard, Studio, pricing, developer guide.
  • src/pages/ — Astro routes; each is a thin shell mounting a React component as a client island.
  • src/registry/registry.json — single source of truth for every component (path, props, status, tags).

2. Technical Stack

Stack layers
Stack layers

Runtime & framework

LayerTechNotes
Build / SSRAstro 6Hybrid: most routes are SSR (prerender = false), some static.
UI islandsReact 19Mounted via client:load / client:visible.
Adapter@astrojs/nodeStandalone server on port 4322 in prod (npm start).
LanguageTypeScriptStrict mode, no any in app code.
Iconsiconsax-reactLinear / Bold / Bulk variants.

Styling & tokens

  • Tailwind CSS v4 — utility-first, no JIT class concatenation for layout-critical styles in islands (see CLAUDE.md: Astro navigation survival rules).
  • CSS custom properties for everything theme-dependent (--theme-bg, --theme-fg, --ri-brand, etc.) so dark/light theme switches don't trigger JIT regenerations.
  • Contrast systemcontrast-text / glass-type-title classes that auto-adapt to page background luminance.
  • Glass primitives — backdrop-filter blur + low-alpha fills.

Tooling

  • Playwright — visual snapshots + E2E in scripts/snap-*.mjs.
  • Registrynpm run registry:check validates every component entry in src/registry/registry.json.
  • ffmpeg — used to compress hero videos (CRF 27, faststart, no audio) → ~85% size reduction.
  • esbuild (via Vite) — fast HMR; watch out for backtick-in-CSS-comment inside </code>...<code> template literals (breaks parsing).
  • Codex CLI + sharp — diagrams in this guide are generated by shelling out to codex exec which writes SVG, then rasterizes via sharp at 1536×1024.

Backend stack (production target)

The showcase ships with localStorage persistence + a single static /api/3d-icons.json endpoint. For production the backend lives inside the same Astro Node process — no separate NestJS / Express service. Everything under /api/* is an Astro server endpoint. See Section 6 for the full architecture; the picks below summarize.

LayerPickWhy
HTTP / routingAstro server endpoints (src/pages/api/**)Same TS types as the React islands; same deploy; SSR-friendly cookie auth.
ORM / DB driverDrizzle + node-postgresSQL-first, no codegen, fast cold-start, edge-compatible. Prisma is the alternative; heavier.
DatabasePostgres (Neon / Supabase / RDS)Schema in section 5. Pick a managed Postgres so we get backups + branching without ops work.
Authbetter-auth (or Lucia v3)Astro-friendly OAuth for Google · LINE · Apple; sessions in Postgres.
PaymentsStripe SDKDirect in Astro endpoints (checkout + portal + signed webhook).
Queue (AI generation)BullMQ + RedisStudio runs take 5–30s — never block an HTTP request. Worker is a separate Node process consuming the queue.
Object storageCloudflare R2 (S3-compatible)No egress fees — meaningful for an icon-download product. Drop-in replaceable with S3.
ValidationZodShared schemas between client + server. Parse-don't-validate at every endpoint.
EmailResend (or Postmark)Transactional only — sign-in confirmations, receipts.
MonitoringSentryServer + client side; ties into roadmap section 12.

3. Sitemap & Pages

Route hierarchy
Route hierarchy

Public routes

RoutePurposeIndexable
/showcases/3d-icon-galleryMarketing home — hero + icon libraryindex, follow ✅
/3d-icons/allFull filterable listingindex, follow ✅
/3d-icons/[tag]Filter slugindex, follow ✅
/3d-icons/collection/[id]Curated bundleindex, follow ✅
/3d-icon/[id]Per-icon detail pageindex, follow ✅ (per-icon og:image)
/collectionsCollection tilesindex, follow ✅
/studioAI generation playgroundindex, follow
/pricingPlan tiers + credit packsindex, follow
/sign-inAuth gateway (Google · LINE · Apple)noindex

Member routes (signed-in)

RoutePurpose
/dashboardWelcome + stats + recent downloads / assets
/dashboard/downloadedFull download library
/dashboard/favoritesLoved icons
/dashboard/assetsUser-generated assets
/dashboard/billingSubscription + payment + invoices
/dashboard/profileIdentity + preferences + delete

Dynamic routes & crawlability

Every /3d-icon/[id] page is generated on demand via Astro's SSR (prerender = false) — the icon catalogue is fetched from /api/3d-icons.json at request time. The SEO discovery pipeline (shipped v0.679/v0.680):

  • ✅ Indexablenoindex removed from all 5 public content routes; robots meta now index, follow.
  • /sitemap.xml — dynamic SSR endpoint at src/pages/sitemap.xml.ts scans the icon folders at request time and emits 214 URLs (landings + categories + collections + every per-icon detail).
  • /robots.txt — points at /sitemap.xml, disallows /dashboard/, /sign-in, /api/.
  • ✅ Crawlable tiles — every .il-tile in IconGrid and every .idm-related-tile in IconDetailModal renders as a real <a href={iconDetailPath(id)}> anchor. preventDefault() on plain clicks preserves the modal UX; modifier clicks (cmd / ctrl / shift / middle-mouse) pass through to native anchor behaviour for "open in new tab".
  • ✅ SSR'd full pool — every grid route (home, /3d-icons/[tag], /3d-icons/collection/[id], /3d-icon/[id]) pre-fetches ?style=all server-side and threads it as initialIcons to the React island, so initial HTML ships 171 tile anchors. Googlebot doesn't need to execute the client fetch or scroll.
  • ✅ Per-icon Open Graph[id].astro passes ogImage directly to BaseLayout (no generic fallback) and sets ogType="article".

4. Features

Feature overview
Feature overview
  • Brick grid with deterministic infinite scroll (buildBatch).
  • Sticky search bar + autocomplete + filter chips.
  • Detail modal (in-place) + detail page (deep-linkable).
  • Per-icon love state in useIconLove, persisted in localStorage.
  • Collections — 20 curated sets with tile + detail.

AI Studio

  • Cursor-following dot-spotlight background (auto-loop when cursor leaves).
  • Prompt bar with attach, aspect-ratio popover, generate.
  • Per-image actions: regenerate (single tile), download, remove.
  • Lightbox with slide / fade transitions.
  • Generated feed + starter examples block at the bottom.

Member dashboard

  • Welcome header with tier badge + Become Pro + credit refill modals (full pricing-tier UI + animated credit refill).
  • Stat cards: downloads / favorites / generated assets / credits.
  • Side rail with viewport-clamp logic (won't overlap the footer).
  • Avatar menu portal: Dashboard, Downloaded, Favorites, Assets, Billing, User Profile, Sign Out (with confirm modal).
  • Profile: avatar upload + crop modal, type-to-confirm delete account.
  • Billing: subscription details + payment info + 12 invoices.

5. Database Tables (Suggested schema)

The showcase is fully front-end (icons from /api/3d-icons.json, state in localStorage). For production, here's the minimal Postgres schema to drive the same surface. SQL is illustrative; switch to your ORM of choice.

Users & auth

-- 5.1 users
CREATE TABLE users (
  id           uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email        citext UNIQUE NOT NULL,
  title        text,          -- Mr. / Ms. / Mrs. / Mx. / Dr.
  first_name   text,
  last_name    text,
  handle       citext UNIQUE, -- @uzui
  role         text,
  company      text,
  city         text,
  country      text,
  bio          text,
  website      text,
  twitter      text,
  avatar_url   text,
  joined_at    timestamptz NOT NULL DEFAULT now(),
  tier         text NOT NULL DEFAULT 'free',  -- free | designer | studio
  credit_left  integer NOT NULL DEFAULT 50,
  credit_total integer NOT NULL DEFAULT 50,
  created_at   timestamptz NOT NULL DEFAULT now(),
  updated_at   timestamptz NOT NULL DEFAULT now()
);

-- 5.2 auth_providers (OAuth: google / line / apple)
CREATE TABLE auth_providers (
  id            bigserial PRIMARY KEY,
  user_id       uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider      text NOT NULL,   -- 'google' | 'line' | 'apple'
  provider_uid  text NOT NULL,
  email         citext,
  created_at    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (provider, provider_uid)
);

-- 5.3 sessions (server-side, cookie-keyed)
CREATE TABLE sessions (
  id           text PRIMARY KEY,  -- random opaque token
  user_id      uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  expires_at   timestamptz NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

Icons + collections

-- 5.4 icons (catalog)
CREATE TABLE icons (
  id           text PRIMARY KEY,  -- 'baby-penguin-with-fish_256px'
  label        text NOT NULL,
  src_url      text NOT NULL,
  kind         text NOT NULL,     -- 'image' | 'video'
  style        text,              -- 'realistic' | 'clay' | 'isometric' | 'video'
  tag          text,              -- primary category
  categories   text[] DEFAULT ARRAY[]::text[],
  tags         text[] DEFAULT ARRAY[]::text[],
  width        integer,
  height       integer,
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX icons_tag_idx ON icons (tag);
CREATE INDEX icons_categories_gin ON icons USING gin (categories);

-- 5.5 collections (curated)
CREATE TABLE collections (
  id           text PRIMARY KEY,  -- 'office-life'
  label        text NOT NULL,
  description  text,
  cover_url    text,
  created_at   timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE collection_icons (
  collection_id text NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
  icon_id       text NOT NULL REFERENCES icons(id) ON DELETE CASCADE,
  position      integer NOT NULL DEFAULT 0,
  PRIMARY KEY (collection_id, icon_id)
);

-- 5.6 favorites (loved icons per user)
CREATE TABLE favorites (
  user_id    uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  icon_id    text NOT NULL REFERENCES icons(id) ON DELETE CASCADE,
  loved_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (user_id, icon_id)
);

-- 5.7 downloads (history)
CREATE TABLE downloads (
  id          bigserial PRIMARY KEY,
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  icon_id     text NOT NULL REFERENCES icons(id) ON DELETE CASCADE,
  size        text,            -- '256' | '1024' | '2048' | 'svg'
  credit_cost integer NOT NULL DEFAULT 1,
  created_at  timestamptz NOT NULL DEFAULT now()
);

Billing + credits

-- 5.8 subscriptions (Stripe-backed)
CREATE TABLE subscriptions (
  id                  bigserial PRIMARY KEY,
  user_id             uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  stripe_customer_id  text NOT NULL,
  stripe_sub_id       text UNIQUE,
  tier                text NOT NULL,   -- 'designer' | 'studio'
  cadence             text NOT NULL,   -- 'monthly' | 'yearly'
  status              text NOT NULL,   -- 'active' | 'past_due' | 'canceled'
  current_period_end  timestamptz,
  created_at          timestamptz NOT NULL DEFAULT now()
);

-- 5.9 invoices (history shown on /billing)
CREATE TABLE invoices (
  id              bigserial PRIMARY KEY,
  user_id         uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  number          text UNIQUE NOT NULL,  -- 'INV-A12401'
  plan            text NOT NULL,
  cycle           text NOT NULL,
  seats           integer NOT NULL DEFAULT 1,
  amount_cents    integer NOT NULL,
  status          text NOT NULL,         -- 'paid' | 'pending' | 'failed'
  stripe_inv_id   text UNIQUE,
  billed_at       timestamptz NOT NULL,
  created_at      timestamptz NOT NULL DEFAULT now()
);

-- 5.10 credit_transactions (wallet ledger)
CREATE TABLE credit_transactions (
  id          bigserial PRIMARY KEY,
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  kind        text NOT NULL,    -- 'pack' | 'monthly' | 'spend' | 'refund'
  delta       integer NOT NULL, -- + for top-up, - for spend
  ref         text,             -- invoice id, generation id, etc.
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- 5.11 payment_methods (card on file)
CREATE TABLE payment_methods (
  id              bigserial PRIMARY KEY,
  user_id         uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  stripe_pm_id    text UNIQUE NOT NULL,
  brand           text,
  last4           text,
  exp_month       integer,
  exp_year        integer,
  is_default      boolean NOT NULL DEFAULT false,
  created_at      timestamptz NOT NULL DEFAULT now()
);

Studio generations

-- 5.12 generations (one row per Generate click)
CREATE TABLE generations (
  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id     uuid NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  prompt      text NOT NULL,
  aspect      text NOT NULL,   -- '1:1' | '16:9' | '4:3' | '3:4' | '9:16'
  outputs     integer NOT NULL,
  attached    text[] DEFAULT ARRAY[]::text[],  -- reference image URLs
  status      text NOT NULL,   -- 'queued' | 'running' | 'done' | 'failed'
  credit_cost integer NOT NULL DEFAULT 5,
  created_at  timestamptz NOT NULL DEFAULT now()
);

-- 5.13 generation_outputs (1..N per generation)
CREATE TABLE generation_outputs (
  id            uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  generation_id uuid NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
  output_url    text NOT NULL,
  kind          text NOT NULL,    -- 'image' | 'video'
  position      integer NOT NULL DEFAULT 0,
  is_removed    boolean NOT NULL DEFAULT false,
  created_at    timestamptz NOT NULL DEFAULT now()
);
Database ERD
Database ERD

6. API Guide

Web → API → DB (three-layer architecture)

The API is Astro. Every route under /api/* is a server endpoint living in the same Node process as the SSR pages — there is no separate NestJS / Express / Hono service. The browser talks to Astro over HTTP; everything past that point is in-process function calls down to Postgres.

Web → API → DB three-layer architecture
Web → API → DB three-layer architecture

The arrows between HTTP layer → service layer → data layer are function calls in the same process, not HTTP. Only the browser → API arrow goes over the wire. That's what makes this architecture cheap to operate compared to a separate-service setup, and why we don't need NestJS to enforce module boundaries — plain TypeScript imports already do that.

  • HTTP layer (src/pages/api/**/*.ts) — One file per route. Each handler is ~10–30 lines: parse params (Zod), call requireUser() if auth-protected, call a service, return JSON. NO business logic in this layer.
  • Service layer (src/server/services/**.ts) — Where the rules live: credit transactions, favorite toggles with atomic counter updates, generation enqueue with credit-lock-then-charge. Importable from endpoints OR from the worker.
  • Data layer (src/server/db/**) — Drizzle schema + a single connection pool. Services compose db.select() / db.insert() / db.transaction() calls.
  • Worker process (worker/index.ts) — The ONE thing that runs in its own process: the BullMQ consumer for Studio AI generation. Imports the same src/server/** code, just has no HTTP surface of its own.

REST endpoints (proposed)

The showcase only ships GET /api/3d-icons.json (static catalog). For production, all member-bound routes require a session cookie set during the auth flow.

MethodPathPurpose
GET/api/iconsList icons, filterable by tag/category/style.
GET/api/icons/:idSingle icon detail.
GET/api/collectionsAll curated collections.
GET/api/collections/:idCollection + its icons.
POST/api/favorites/:iconIdToggle favorite (auth).
GET/api/meCurrent user + tier + credit balance.
PATCH/api/meUpdate profile fields.
POST/api/me/avatarUpload + crop avatar (returns 320×320 URL).
DELETE/api/meDelete account (gated by type-to-confirm).
POST/api/downloadsRecord a download, spend 1 credit.
GET/api/downloadsUser download history.
POST/api/generationsStart a Studio run (prompt, aspect, outputs).
GET/api/generationsUser's generation feed.
POST/api/generations/:id/regenerateRegenerate one output by index.
DELETE/api/generations/:idRemove a whole generation.
POST/api/billing/checkoutCreate Stripe checkout session for plan or credit pack.
POST/api/billing/portalStripe customer-portal redirect.
POST/api/billing/webhookStripe events (signed).
POST/api/auth/:providerOAuth start: google / line / apple.
GET/api/auth/callbackOAuth return.
POST/api/auth/sign-outInvalidate session.

Worked example — favorite toggle

Trace one user action end-to-end to see exactly what code lives where. The user clicks the ❤ on an icon →

1. Browser — React island fires the request

// src/showcases/3d-icon-gallery/IconDetailModal.tsx
const toggleLove = async () => {
  const res = await fetch(`/api/favorites/${iconId}`, {
    method: "POST",
    credentials: "include",   // session cookie comes along
  });
  const { loved, count } = await res.json();
  setLoved(loved);
  setCount(count);
};

2. HTTP layer — Astro endpoint at src/pages/api/favorites/[iconId].ts

import type { APIRoute } from "astro";
import { z } from "zod";
import { requireUser } from "@/server/auth";
import { toggleFavorite } from "@/server/services/favorites";

export const prerender = false;
const ParamsSchema = z.object({ iconId: z.string().min(1).max(200) });

export const POST: APIRoute = async ({ params, cookies }) => {
  const user = await requireUser(cookies);          // 401 if no session
  const { iconId } = ParamsSchema.parse(params);
  const result = await toggleFavorite(user.id, iconId);
  return new Response(JSON.stringify(result), {
    status: 200,
    headers: { "Content-Type": "application/json" },
  });
};

~15 lines of glue. Its only job: parse, authorize, call the service, return JSON. No business logic here.

3. Service layer — src/server/services/favorites.ts

import { db } from "@/server/db/client";
import { favorites, iconFavoriteCounts } from "@/server/db/schema";
import { and, eq, sql } from "drizzle-orm";

export async function toggleFavorite(userId: string, iconId: string) {
  return await db.transaction(async (tx) => {
    const existing = await tx.select().from(favorites)
      .where(and(eq(favorites.userId, userId), eq(favorites.iconId, iconId)))
      .limit(1);

    if (existing.length > 0) {
      await tx.delete(favorites).where(
        and(eq(favorites.userId, userId), eq(favorites.iconId, iconId)),
      );
      const [{ count }] = await tx.update(iconFavoriteCounts)
        .set({ count: sql`count - 1` })
        .where(eq(iconFavoriteCounts.iconId, iconId))
        .returning({ count: iconFavoriteCounts.count });
      return { loved: false, count };
    }

    await tx.insert(favorites).values({ userId, iconId });
    const [{ count }] = await tx.insert(iconFavoriteCounts)
      .values({ iconId, count: 1 })
      .onConflictDoUpdate({
        target: iconFavoriteCounts.iconId,
        set: { count: sql`${iconFavoriteCounts.count} + 1` },
      })
      .returning({ count: iconFavoriteCounts.count });
    return { loved: true, count };
  });
}

This is where the business rules live: toggling is atomic (the transaction), per-icon count stays in sync. If you wanted to email the user when they hit their 100th favorite, this is where it'd go — not the endpoint file.

4. Data layer — src/server/db/schema.ts

import { pgTable, text, integer, timestamp, primaryKey } from "drizzle-orm/pg-core";

export const favorites = pgTable("favorites", {
  userId: text("user_id").notNull(),
  iconId: text("icon_id").notNull(),
  createdAt: timestamp("created_at").defaultNow().notNull(),
}, (t) => ({ pk: primaryKey({ columns: [t.userId, t.iconId] }) }));

export const iconFavoriteCounts = pgTable("icon_favorite_counts", {
  iconId: text("icon_id").primaryKey(),
  count: integer("count").notNull().default(0),
});

Schema is just typed table definitions. Drizzle generates the SQL migrations via drizzle-kit generate.

Studio worker — the one async exception

Everything above is synchronous request-response. Studio AI generation is the one place the architecture splits into two processes — because each generation takes 5–30 seconds calling an external AI provider, which is too long to block an HTTP request on.

Studio AI generation — async worker flow
Studio AI generation — async worker flow
  • Credit lock-then-charge — the endpoint debits credits transactionally as part of the enqueue. If the job ultimately fails, the worker refunds them in the same transaction it marks the row failed.
  • Per-user concurrency — BullMQ limiter with groupKey: userId caps how many jobs one user can run in parallel (≈ 2). Prevents one heavy user from starving the queue.
  • Retries — BullMQ retries with exponential backoff (3 attempts) on transient AI-provider errors. Permanent failures mark the row failed + refund credits.
  • Progress to UI — initial impl: browser polls GET /api/generations/:id every 2 s. v2: switch to SSE or WebSocket for instant updates.
  • Same TypeScript codebase — the worker (worker/index.ts) imports src/server/services/generations.ts directly. One repo, one type system, two entry points.

Auth flow

API request flows — sign-in and authenticated call
API request flows — sign-in and authenticated call

Webhooks

  • StripePOST /api/billing/webhook — verify signature, update subscriptions, invoices, credit_transactions.
  • Generation worker → if you prefer worker→endpoint HTTP over direct DB writes, expose POST /api/internal/generations/:id/complete protected by an HMAC secret. The simpler default: the worker writes to Postgres directly via src/server/services/generations.ts (same code the endpoint uses) — no extra HTTP hop needed.

7. Meta-prompt to recreate the full app

Paste this prompt into a fresh Claude or ChatGPT session (with file-write tools enabled) to reconstruct the entire project from the design rules + this guide. Replace the bracketed placeholders with your actual values.

SYSTEM ROLE
You are a senior full-stack engineer building a production-grade
3D icon library website on Astro 6 + React 19 + Tailwind CSS v4
+ TypeScript, deployed to [Vercel / Fly / Railway]. You write
clean, well-commented code that follows GelUI design rules
(see CLAUDE.md). You verify every change with Playwright snaps
before declaring it done.

PROJECT
Re-create the "3D Icon Gallery" showcase as a real product:
- Public marketing site: home hero, 3D Icons listing
  (/3d-icons/all + /3d-icons/[tag]), per-icon detail (/3d-icon/[id]),
  curated /collections + /collections/[id], /studio (AI
  generation), /pricing, /sign-in.
- Member shell at /dashboard with side rail + avatar menu:
  Dashboard, Downloaded, Favorites, Assets, Billing, User
  Profile, Settings.
- Theme-aware (light + dark), full responsive (375px → 1920px+).
- iOS Safari autoplay fallback to image on the hero.

STACK
- Astro 6 SSR (Node adapter), React 19 islands.
- Tailwind v4 utilities + scoped CSS-in-JS for component-local
  rules.
- Postgres ([Supabase / Neon]) for persistence.
- [S3 / Cloudflare R2] for icon + generation storage.
- [Stripe] for billing.
- [Replicate / Modal / vendor SDK] for AI generation.

DATABASE SCHEMA
Use the schema in section 5 of the Developer Guide
(/showcases/3d-icon-gallery/developer-guide#database).

API CONTRACT
Implement every endpoint in section 6. Use session cookies for
auth, HMAC for internal webhooks, Stripe signature verification
on the billing webhook.

DESIGN RULES (MUST FOLLOW)
1. Never use Math.random() / Date.now() during render — SSR
   must match first client paint.
2. Never read window/document/localStorage during render — only
   in useEffect.
3. Inline style objects must use strings ("3px") not numbers (3).
4. Tailwind v4 @utility blocks: no var() with commas, no
   ancestor selectors.
5. Astro navigation: never use dark: classes — use CSS vars on
   :root[data-theme="dark"].
6. Arbitrary Tailwind classes for layout-critical styles in
   React islands are unreliable post-navigation — use inline
   styles or CSS vars instead.
7. Run npm run registry:check after adding/changing components.

DELIVERABLES (in order, each verified before moving on)
1. Auth flow (/sign-in → Google/LINE/Apple → /dashboard).
2. Icon catalog + listing + detail (with love + share).
3. Collections.
4. Member dashboard + downloads.
5. Favorites + assets surfaces.
6. Studio (mock first, then wire to real AI vendor).
7. Pricing + billing (Stripe checkout + portal + webhook).
8. Profile + avatar crop + delete account.
9. Sitemap.xml + robots.txt + per-page meta + Open Graph
   images.
10. Playwright suite + CI.
11. Deploy + monitoring.

For each deliverable: write the code, run a Playwright snap, post the screenshot, then commit.

START with the auth flow.

8. Deployment

Deployment pipeline
Deployment pipeline
  • Local dev: npm run dev (port 4321). Production preview: npm run build && npm start (port 4322).
  • Build output: dist/server + dist/client (Astro Node adapter). Static assets in dist/client/_astro/.
  • Hosting: any Node 20+ host. Recommended: Fly.io (small VM + Postgres add-on), Railway, or Render. Avoid serverless for SSR — cold starts hurt the dashboard.
  • Environment: SITE_URL, DATABASE_URL, SESSION_SECRET, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, OAUTH_*_CLIENT_ID / SECRET per provider, AI_API_KEY.
  • CDN: Cloudflare in front of the origin. Cache /_astro/* and /resources/* aggressively (immutable, 1y).
  • Image / video hosting: generation outputs + uploaded avatars → object storage; deliver via signed URLs from /api/me/avatar etc.
  • CI: GitHub Actions — typecheck, build, Playwright suite, deploy on tag push.

9. Testing

  • Visual snaps: scripts/snap-*.mjs spin up Playwright + Chromium, take cropped screenshots, write to /tmp/. Used during dev for visual verification.
  • Smoke flows: add Playwright tests under tests/ covering sign-in, generate, refill, delete-account.
  • Component contract: use Vitest + React Testing Library for primitive logic (hooks like useIconLove, drag math in MemberAvatarUploadModal).
  • Visual regression: Percy / Chromatic on PRs.
  • Accessibility: axe-core run inside Playwright (@axe-core/playwright) on every key route.

10. Performance & Accessibility

  • Lazy-load below-the-fold: all icon grid <img> already use loading="lazy".
  • Optimize media: hero videos compressed with ffmpeg CRF 27 + faststart (−85% size). Apply the same to any large mockup.
  • Font loading: blocking script in BaseLayout.astro restores presets from localStorage before paint — keep an eye on CLS.
  • Bundle size: check with astro build --analyze. iconsax-react is tree-shaken per import; never import * from "iconsax-react".
  • Keyboard nav: every interactive surface needs visible :focus-visible ring. Modals trap focus + restore on close. Side rail items use aria-current="page".
  • Color contrast: body text ≥ 4.5:1 against bg in both themes.
  • Reduced motion: respect prefers-reduced-motion: reduce — already wired in Studio scroll-to-top and a few transitions.

11. Security

  • Session cookies: HttpOnly, Secure, SameSite=Lax, signed with SESSION_SECRET. Rotate on auth event.
  • CSRF: mutating endpoints accept POST + double-submit cookie pattern.
  • Rate limiting: per-IP and per-user. Tighter limits on POST /api/generations and OAuth callbacks.
  • Input validation: zod schemas on every route handler.
  • File uploads: validate MIME (image/*), strip EXIF, recompress on the server, never serve user uploads from the app origin (use signed CDN URLs).
  • Headers: Content-Security-Policy, Strict-Transport-Security, X-Content-Type-Options: nosniff, Referrer-Policy: strict-origin-when-cross-origin.
  • Dependency scan: npm audit in CI; github/dependabot for weekly bumps.
  • Stripe webhook: verify signature on every event before processing.

12. Roadmap (audit recommendations)

Findings from the audit on this branch (current state vs. production-ready). Items are grouped by status; open items are ordered by priority.

Recently shipped (SEO foundation)

  • ✅ Removed noindex on the 5 public content routes/, /collections, /3d-icons/[tag], /3d-icons/collection/[id], /3d-icon/[id]. Robots meta now reads index, follow. (v0.679.0)
  • ✅ Gallery tiles are real anchor tags — every .il-tile in IconGrid and every .idm-related-tile in IconDetailModal renders as <a href={iconDetailPath(id)}> with preventDefault() on plain clicks (modal UX preserved) and pass-through on modifier clicks (cmd / ctrl / shift / middle-mouse → new tab). (v0.679.0)
  • ✅ Dynamic /sitemap.xml — server-rendered route that scans public/resources/3d-icons (+ -clay, -isometric, -video) at request time. Emits 214 URLs: 5 landings + 15 categories + 20 curated bundles + every per-icon detail. New files in the folder appear on the very next request, no manifest rebuild. (v0.679.0)
  • /robots.txt — disallows /dashboard/, /sign-in, /api/; points at /sitemap.xml. (v0.679.0)
  • ✅ Per-icon Open Graph image — the per-icon detail page passes ogImage directly to BaseLayout (no more generic GelUI fallback) and sets ogType="article". (v0.679.0)
  • ✅ SSR the full icon pool on every grid routeindex.astro, 3d-icons/[tag].astro, 3d-icons/collection/[id].astro, and 3d-icon/[id].astro all pre-fetch /api/3d-icons.json?style=all server-side and thread the result into the React island as initialIcons. Initial HTML now ships 171 tile anchors (was 24 BASE_ICONS), so Googlebot sees every icon URL without executing the client fetch or scrolling. (v0.680.0)

Next step — pagination strategy when the pool grows

The current gallery hosts 171 distinct icons, all shipped in the initial SSR HTML on the home page and /3d-icons/all. The "infinite scroll" UX appends shuffled re-batches of the same pool (synthetic __b1, __b2 suffixes) up to MAX_ITEMS = 240; it does not reveal new distinct icons. Google therefore has complete coverage today without any ?page=N URLs.

This becomes inadequate when the pool crosses ~1,000 icons: shipping 1k+ tile anchors in one HTML payload makes the response heavy (~500 KB+), slows first paint, and produces a sprawling DOM that hurts Core Web Vitals. At that point we want paginated URLs alongside infinite scroll, not in place of it.

Trigger to build it: when any of these conditions hits —

  • Distinct icon pool crosses ~1,000 (today: 171).
  • Largest ?style=all response crosses ~200 KB.
  • Users start asking for bookmarkable scroll positions ("page 4 of Animals").
  • You start chasing category-specific SEO terms and want each ?page=N URL to rank as its own indexable surface.

Recommended implementation (the "hybrid" pattern):

  1. Server-side paging — extend the API to /api/3d-icons.json?style=all&page=N&pageSize=60. Default pageSize ≈ 60 (≈ 5 rows of brick-pattern grid). Return a { items, page, totalPages, total } envelope.
  2. Page-route SSR3d-icons/[tag].astro reads ?page from Astro.url.searchParams, fetches that page server-side, and passes initialIcons + initialPage to the React island. Initial HTML ships exactly one page of anchors per request.
  3. Crawler-friendly head links — emit <link rel="prev" href="?page=N-1"> and <link rel="next" href="?page=N+1"> in BaseLayout for paginated pages. Each ?page=N URL gets its own canonical and is independently indexable.
  4. Visible Prev/Next pager — render a real anchor-based pager beneath the grid. Crawlers follow it; users still get the infinite-scroll UX on top.
  5. Infinite-scroll merges with URL — as the user scrolls past each batch, replace state with history.replaceState(null, "", "?page=N") so the URL tracks the deepest page seen. Refreshing or sharing the URL drops them at the right scroll depth.
  6. Sitemap fan-out — extend /sitemap.xml to emit one entry per ?page=N URL per tag, so the sitemap stays the authoritative discovery channel for the new surfaces too.

Estimated effort: ~1 dev-day (server + URL state + head links + sitemap fan-out), plus a Playwright suite covering: load ?page=3 directly, prev/next pager works, scroll- triggered URL replace, rel="next" present in head.

Open work (other priorities)

  1. Submit /sitemap.xml to Google Search Console — one-time operational task after the first production deploy. Verifies the property, gives Google a direct prompt to crawl the 214 URLs instead of waiting for organic discovery, and surfaces crawl-error reporting.
  2. Internationalization — the language picker already exists but only English copy ships. Use astro-i18n with locale prefix (/en/, /th/, /ja/). Add hreflang meta to BaseLayout.
  3. Analytics + product metrics — wire PostHog or Plausible; track sign-in conversion, generate clicks, refill funnel.
  4. Error monitoring — Sentry on both Astro server + React islands.
  5. CI/CD — no GitHub Actions yet. Add a workflow that runs typecheck, build, Playwright suite on every PR + tag.
  6. Image optimization pipeline — auto-generate AVIF/WebP variants from PNGs in /resources/3d-icons/ at build, serve via <picture>. Critical for Core Web Vitals once we ship 171+ tiles in initial HTML.
  7. Real backend — replace /api/3d-icons.json + localStorage persistence with the schema in section 5.
  8. Tests — add a smoke Playwright suite that walks sign-in → favorite → refill → sign out.
  9. Service-worker offline — cache the icon grid so the catalogue works offline after first visit.