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

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

Runtime & framework
| Layer | Tech | Notes |
|---|---|---|
| Build / SSR | Astro 6 | Hybrid: most routes are SSR (prerender = false), some static. |
| UI islands | React 19 | Mounted via client:load / client:visible. |
| Adapter | @astrojs/node | Standalone server on port 4322 in prod (npm start). |
| Language | TypeScript | Strict mode, no any in app code. |
| Icons | iconsax-react | Linear / 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 system —
contrast-text/glass-type-titleclasses that auto-adapt to page background luminance. - Glass primitives — backdrop-filter blur + low-alpha fills.
Tooling
- Playwright — visual snapshots + E2E in
scripts/snap-*.mjs. - Registry —
npm run registry:checkvalidates every component entry insrc/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-commentinside</code>...<code>template literals (breaks parsing). - Codex CLI +
sharp— diagrams in this guide are generated by shelling out tocodex execwhich 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.
| Layer | Pick | Why |
|---|---|---|
| HTTP / routing | Astro server endpoints (src/pages/api/**) | Same TS types as the React islands; same deploy; SSR-friendly cookie auth. |
| ORM / DB driver | Drizzle + node-postgres | SQL-first, no codegen, fast cold-start, edge-compatible. Prisma is the alternative; heavier. |
| Database | Postgres (Neon / Supabase / RDS) | Schema in section 5. Pick a managed Postgres so we get backups + branching without ops work. |
| Auth | better-auth (or Lucia v3) | Astro-friendly OAuth for Google · LINE · Apple; sessions in Postgres. |
| Payments | Stripe SDK | Direct in Astro endpoints (checkout + portal + signed webhook). |
| Queue (AI generation) | BullMQ + Redis | Studio runs take 5–30s — never block an HTTP request. Worker is a separate Node process consuming the queue. |
| Object storage | Cloudflare R2 (S3-compatible) | No egress fees — meaningful for an icon-download product. Drop-in replaceable with S3. |
| Validation | Zod | Shared schemas between client + server. Parse-don't-validate at every endpoint. |
| Resend (or Postmark) | Transactional only — sign-in confirmations, receipts. | |
| Monitoring | Sentry | Server + client side; ties into roadmap section 12. |
3. Sitemap & Pages

Public routes
| Route | Purpose | Indexable |
|---|---|---|
/showcases/3d-icon-gallery | Marketing home — hero + icon library | index, follow ✅ |
/3d-icons/all | Full filterable listing | index, follow ✅ |
/3d-icons/[tag] | Filter slug | index, follow ✅ |
/3d-icons/collection/[id] | Curated bundle | index, follow ✅ |
/3d-icon/[id] | Per-icon detail page | index, follow ✅ (per-icon og:image) |
/collections | Collection tiles | index, follow ✅ |
/studio | AI generation playground | index, follow |
/pricing | Plan tiers + credit packs | index, follow |
/sign-in | Auth gateway (Google · LINE · Apple) | noindex |
Member routes (signed-in)
| Route | Purpose |
|---|---|
/dashboard | Welcome + stats + recent downloads / assets |
/dashboard/downloaded | Full download library |
/dashboard/favorites | Loved icons |
/dashboard/assets | User-generated assets |
/dashboard/billing | Subscription + payment + invoices |
/dashboard/profile | Identity + 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):
- ✅ Indexable —
noindexremoved from all 5 public content routes; robots meta nowindex, follow. - ✅
/sitemap.xml— dynamic SSR endpoint atsrc/pages/sitemap.xml.tsscans 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-tileinIconGridand every.idm-related-tileinIconDetailModalrenders 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=allserver-side and threads it asinitialIconsto 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].astropassesogImagedirectly toBaseLayout(no generic fallback) and setsogType="article".
4. Features

Icon gallery
- 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 inlocalStorage. - 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()
);
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.

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), callrequireUser()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 composedb.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 samesrc/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.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/icons | List icons, filterable by tag/category/style. |
| GET | /api/icons/:id | Single icon detail. |
| GET | /api/collections | All curated collections. |
| GET | /api/collections/:id | Collection + its icons. |
| POST | /api/favorites/:iconId | Toggle favorite (auth). |
| GET | /api/me | Current user + tier + credit balance. |
| PATCH | /api/me | Update profile fields. |
| POST | /api/me/avatar | Upload + crop avatar (returns 320×320 URL). |
| DELETE | /api/me | Delete account (gated by type-to-confirm). |
| POST | /api/downloads | Record a download, spend 1 credit. |
| GET | /api/downloads | User download history. |
| POST | /api/generations | Start a Studio run (prompt, aspect, outputs). |
| GET | /api/generations | User's generation feed. |
| POST | /api/generations/:id/regenerate | Regenerate one output by index. |
| DELETE | /api/generations/:id | Remove a whole generation. |
| POST | /api/billing/checkout | Create Stripe checkout session for plan or credit pack. |
| POST | /api/billing/portal | Stripe customer-portal redirect. |
| POST | /api/billing/webhook | Stripe events (signed). |
| POST | /api/auth/:provider | OAuth start: google / line / apple. |
| GET | /api/auth/callback | OAuth return. |
| POST | /api/auth/sign-out | Invalidate 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.

- 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
limiterwithgroupKey: userIdcaps 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/:idevery 2 s. v2: switch to SSE or WebSocket for instant updates. - Same TypeScript codebase — the worker (
worker/index.ts) importssrc/server/services/generations.tsdirectly. One repo, one type system, two entry points.
Auth flow

Webhooks
- Stripe →
POST /api/billing/webhook— verify signature, updatesubscriptions,invoices,credit_transactions. - Generation worker → if you prefer worker→endpoint HTTP over direct DB writes, expose
POST /api/internal/generations/:id/completeprotected by an HMAC secret. The simpler default: the worker writes to Postgres directly viasrc/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

- 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 indist/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/SECRETper 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/avataretc. - CI: GitHub Actions — typecheck, build, Playwright suite, deploy on tag push.
9. Testing
- Visual snaps:
scripts/snap-*.mjsspin 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 inMemberAvatarUploadModal). - 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 useloading="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.astrorestores presets from localStorage before paint — keep an eye on CLS. - Bundle size: check with
astro build --analyze. iconsax-react is tree-shaken per import; neverimport * from "iconsax-react". - Keyboard nav: every interactive surface needs visible
:focus-visiblering. Modals trap focus + restore on close. Side rail items usearia-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 withSESSION_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/generationsand 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 auditin CI;github/dependabotfor 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
noindexon the 5 public content routes —/,/collections,/3d-icons/[tag],/3d-icons/collection/[id],/3d-icon/[id]. Robots meta now readsindex, follow. (v0.679.0) - ✅ Gallery tiles are real anchor tags — every
.il-tileinIconGridand every.idm-related-tileinIconDetailModalrenders as<a href={iconDetailPath(id)}>withpreventDefault()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 scanspublic/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
ogImagedirectly toBaseLayout(no more generic GelUI fallback) and setsogType="article". (v0.679.0) - ✅ SSR the full icon pool on every grid route —
index.astro,3d-icons/[tag].astro,3d-icons/collection/[id].astro, and3d-icon/[id].astroall pre-fetch/api/3d-icons.json?style=allserver-side and thread the result into the React island asinitialIcons. 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=allresponse 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=NURL to rank as its own indexable surface.
Recommended implementation (the "hybrid" pattern):
- Server-side paging — extend the API to
/api/3d-icons.json?style=all&page=N&pageSize=60. DefaultpageSize≈ 60 (≈ 5 rows of brick-pattern grid). Return a{ items, page, totalPages, total }envelope. - Page-route SSR —
3d-icons/[tag].astroreads?pagefromAstro.url.searchParams, fetches that page server-side, and passesinitialIcons+initialPageto the React island. Initial HTML ships exactly one page of anchors per request. - Crawler-friendly head links — emit
<link rel="prev" href="?page=N-1">and<link rel="next" href="?page=N+1">inBaseLayoutfor paginated pages. Each?page=NURL gets its own canonical and is independently indexable. - 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.
- 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. - Sitemap fan-out — extend
/sitemap.xmlto emit one entry per?page=NURL 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)
- Submit
/sitemap.xmlto 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. - Internationalization — the language picker already exists but only English copy ships. Use
astro-i18nwith locale prefix (/en/,/th/,/ja/). Addhreflangmeta to BaseLayout. - Analytics + product metrics — wire PostHog or Plausible; track sign-in conversion, generate clicks, refill funnel.
- Error monitoring — Sentry on both Astro server + React islands.
- CI/CD — no GitHub Actions yet. Add a workflow that runs typecheck, build, Playwright suite on every PR + tag.
- 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. - Real backend — replace
/api/3d-icons.json+localStoragepersistence with the schema in section 5. - Tests — add a smoke Playwright suite that walks sign-in → favorite → refill → sign out.
- Service-worker offline — cache the icon grid so the catalogue works offline after first visit.