/SKILL.md
End-to-end workflow for launching a new product landing page — scaffolding, theming, analytics, waitlist, domain, SEO, and deployment.
npx skillsauth add m13v/new-product-website new-product-websiteInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
Automates the full launch workflow for a new product landing page: scaffold Next.js app, configure theme, wire analytics + waitlist, deploy to Google Cloud Run, configure domain, and register with Google Search Console.
Provide the product name, domain, and a brief description. Example: "MyApp at myapp.com — AI-powered task management"
{product}_emails table)mkdir ~/PROJECT_NAME && cd ~/PROJECT_NAME
Create these files manually (don't use create-next-app — it hangs on interactive prompts):
| File | Purpose |
|------|---------|
| package.json | next 15, react 19, framer-motion, lucide-react, posthog-js, @neondatabase/serverless |
| tsconfig.json | Standard Next.js TS config with @/* path alias |
| next.config.ts | output: "standalone" for Cloud Run Docker builds |
| postcss.config.mjs | tailwindcss + autoprefixer |
| tailwind.config.ts | Custom theme (accent color, fonts, animations) |
| src/app/globals.css | Dark theme, gradient-text, noise-overlay, grid-bg |
| src/app/layout.tsx | Root layout with metadata, OG tags, PostHogProvider |
| src/app/page.tsx | Compose all sections |
Standard landing page sections (adapt content per product):
phc_)https://us.i.posthog.com or https://eu.i.posthog.com)src/components/posthog-provider.tsx:"use client";
import posthog from "posthog-js";
import { PostHogProvider as PHProvider } from "posthog-js/react";
import { useEffect } from "react";
const POSTHOG_KEY = process.env.NEXT_PUBLIC_POSTHOG_KEY;
const POSTHOG_HOST = process.env.NEXT_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (POSTHOG_KEY && typeof window !== "undefined") {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
person_profiles: "identified_only",
capture_pageview: true,
capture_pageleave: true,
});
}
}, []);
if (!POSTHOG_KEY) return <>{children}</>;
return <PHProvider client={posthog}>{children}</PHProvider>;
}
export { posthog };
layout.tsx with <PostHogProvider>DOMAIN# Find the managed zone for your domain
gcloud dns managed-zones list --project=GCP_PROJECT_ID
# DKIM
gcloud dns record-sets create resend._domainkey.DOMAIN. --type=TXT --ttl=300 \
--rrdatas='"DKIM_VALUE"' --zone=DNS_ZONE --project=GCP_PROJECT_ID
# SPF
gcloud dns record-sets create send.DOMAIN. --type=MX --ttl=300 \
--rrdatas='10 feedback-smtp.us-east-1.amazonses.com.' --zone=DNS_ZONE --project=GCP_PROJECT_ID
gcloud dns record-sets create send.DOMAIN. --type=TXT --ttl=300 \
--rrdatas='"v=spf1 include:amazonses.com ~all"' --zone=DNS_ZONE --project=GCP_PROJECT_ID
# DMARC (deliverability + anti-spoofing)
gcloud dns record-sets create _dmarc.DOMAIN. --type=TXT --ttl=300 \
--rrdatas='"v=DMARC1; p=none;"' --zone=DNS_ZONE --project=GCP_PROJECT_ID
@. Add it:gcloud dns record-sets create DOMAIN. --type=MX --ttl=300 \
--rrdatas='10 inbound-smtp.us-east-1.amazonaws.com.' --zone=DNS_ZONE --project=GCP_PROJECT_ID
dig MX DOMAIN +short — should show 10 inbound-smtp.us-east-1.amazonaws.com.Create a {product}_emails table in the project's Neon database:
CREATE TABLE IF NOT EXISTS {product}_emails (
id SERIAL PRIMARY KEY,
resend_id TEXT,
direction TEXT NOT NULL DEFAULT 'inbound',
from_email TEXT,
to_email TEXT,
subject TEXT,
body_text TEXT,
body_html TEXT,
status TEXT DEFAULT 'received',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_{product}_emails_created_at ON {product}_emails(created_at DESC);
Create src/app/api/webhooks/resend/route.ts:
import { NextResponse } from "next/server";
import { neon } from "@neondatabase/serverless";
interface ResendWebhookPayload {
type: string;
created_at: string;
data: {
email_id: string;
from: string;
to: string[];
subject: string;
text?: string;
html?: string;
};
}
async function fetchInboundContent(emailId: string) {
const apiKey = process.env.RESEND_API_KEY;
if (!apiKey) return null;
try {
const res = await fetch(
`https://api.resend.com/emails/receiving/${emailId}`,
{ headers: { Authorization: `Bearer ${apiKey}` } }
);
if (!res.ok) return null;
const data = await res.json();
return { text: data?.text, html: data?.html };
} catch {
return null;
}
}
export async function POST(request: Request) {
try {
const payload: ResendWebhookPayload = await request.json();
console.log("[PRODUCT Webhook]", payload.type, payload.data.email_id);
if (payload.type !== "email.received") {
return NextResponse.json({ success: true, message: "ignored" });
}
const { data } = payload;
// IMPORTANT: Only process emails addressed to @DOMAIN (shared Resend account)
const isForUs = data.to.some((addr) => addr.endsWith("@DOMAIN"));
if (!isForUs) {
return NextResponse.json({ success: true, message: "not for DOMAIN" });
}
const content = await fetchInboundContent(data.email_id);
const sql = neon(process.env.DATABASE_URL!);
await sql`
INSERT INTO {product}_emails (resend_id, direction, from_email, to_email, subject, body_text, body_html, status)
VALUES (${data.email_id}, 'inbound', ${data.from}, ${data.to[0] || ""}, ${data.subject || ""}, ${content?.text || data.text || null}, ${content?.html || data.html || null}, 'received')
`;
// Forward to inbox
const apiKey = process.env.RESEND_API_KEY;
if (apiKey) {
await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "PRODUCT Inbound <matt@DOMAIN>",
to: "[email protected]",
subject: `[PRODUCT Inbound] ${data.subject || "(no subject)"}`,
text: `From: ${data.from}\nTo: ${data.to.join(", ")}\n\n${content?.text || data.text || "(no body)"}`,
}),
});
}
return NextResponse.json({ success: true });
} catch (error) {
console.error("[PRODUCT Webhook] Error:", error);
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
export async function GET() {
return NextResponse.json({ status: "ok" });
}
Replace PRODUCT, DOMAIN, and {product} with actual values.
After deploying (step 5), register the webhook:
curl -X POST "https://api.resend.com/webhooks" \
-H "Authorization: Bearer $RESEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{"endpoint": "https://DOMAIN/api/webhooks/resend", "events": ["email.received"]}'
src/app/api/waitlist/route.ts:import { NextResponse } from "next/server";
import { neon } from "@neondatabase/serverless";
export async function POST(req: Request) {
try {
const { email } = await req.json();
if (!email || !email.includes("@"))
return NextResponse.json({ error: "Invalid email" }, { status: 400 });
const RESEND_API_KEY = process.env.RESEND_API_KEY;
const RESEND_AUDIENCE_ID = process.env.RESEND_AUDIENCE_ID;
if (!RESEND_API_KEY || !RESEND_AUDIENCE_ID)
return NextResponse.json({ error: "Server config error" }, { status: 500 });
// Add contact to audience
const audienceRes = await fetch(
`https://api.resend.com/audiences/${RESEND_AUDIENCE_ID}/contacts`,
{
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ email, unsubscribed: false }),
}
);
if (!audienceRes.ok)
return NextResponse.json({ error: "Failed to subscribe" }, { status: 500 });
// Send welcome email
const emailRes = await fetch("https://api.resend.com/emails", {
method: "POST",
headers: {
Authorization: `Bearer ${RESEND_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
from: "Matt <matt@DOMAIN>",
to: [email],
subject: "You're in — PRODUCT access request received",
html: `<!-- Customize welcome email HTML here -->`,
}),
});
// Log outbound email to DB
try {
const emailData = await emailRes.json().catch(() => null);
const sql = neon(process.env.DATABASE_URL!);
await sql`
INSERT INTO {product}_emails (resend_id, direction, from_email, to_email, subject, status)
VALUES (${emailData?.id || null}, 'outbound', 'matt@DOMAIN', ${email}, ${"Welcome email"}, 'sent')
`;
} catch (logErr) {
console.error("Email log error:", logErr);
}
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: "Internal error" }, { status: 500 });
}
}
/api/waitlist + fires posthog?.capture("waitlist_signup", { email })Install the shared SEO components package for programmatic guide pages, sidebar navigation, AI chat, and structured data helpers.
npm install @m13v/seo-components@latest
# Also create the shorter alias for imports
npm install @seo/components@npm:@m13v/seo-components@latest
Both entries will appear in package.json:
"@m13v/seo-components": "^0.8.15",
"@seo/components": "npm:@m13v/seo-components@^0.8.15"
import type { NextConfig } from "next";
import { withSeoContent } from "@seo/components/next";
const nextConfig: NextConfig = {
output: "standalone",
transpilePackages: ["@seo/components"],
};
export default withSeoContent(nextConfig, { contentDir: "src/app/t" });
withSeoContent wraps your config to enable build-time guide discovery. At next build, it walks src/app/t/ and generates .next/seo-guides-manifest.json so runtime code (sidebar, chat) can read the page inventory without filesystem access.
Add SeoComponentsStyles and HeadingAnchors to src/app/layout.tsx:
import { HeadingAnchors } from "@seo/components";
import { SeoComponentsStyles } from "@seo/components/server";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" className={`${fontVars} h-full antialiased`}>
<head>
<SeoComponentsStyles />
</head>
<body className="min-h-full flex flex-col font-sans">
<HeadingAnchors />
{children}
</body>
</html>
);
}
SeoComponentsStyles injects the package's prebuilt Tailwind CSSHeadingAnchors auto-injects id attributes on H2 elements for sidebar linking and anchor navigationsrc/app/t/{slug}/page.tsxEach guide page imports components and JSON-LD helpers from the package:
import type { Metadata } from "next";
import {
Breadcrumbs,
ArticleMeta,
FaqSection,
InlineCta,
StickyBottomCta,
ProofBand,
AnimatedSection,
StepTimeline,
AnimatedChecklist,
BentoGrid,
articleSchema,
breadcrumbListSchema,
faqPageSchema,
} from "@seo/components";
export const metadata: Metadata = {
title: "Guide Title | PRODUCT",
description: "Guide description for search engines.",
};
export default function GuidePage() {
const jsonLd = [
articleSchema({ title: "...", description: "...", url: "...", datePublished: "..." }),
breadcrumbListSchema([
{ name: "Home", url: "https://DOMAIN" },
{ name: "Guides", url: "https://DOMAIN/t" },
{ name: "Guide Title", url: "https://DOMAIN/t/guide-slug" },
]),
];
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<article className="max-w-3xl mx-auto px-6 py-16">
<Breadcrumbs items={[
{ label: "Home", href: "/" },
{ label: "Guides", href: "/t" },
{ label: "Guide Title" },
]} className="mb-8" />
<AnimatedSection>
<h1 className="text-4xl font-bold mb-4">Guide Title</h1>
<ArticleMeta date="2026-01-15" readTime="8 min" />
</AnimatedSection>
<ProofBand stats={[{ value: "10x", label: "Faster" }]} />
<nav className="bg-gray-50 rounded-lg p-6 mb-12">
{/* Table of contents with anchor links */}
</nav>
<section id="section-1">
<AnimatedSection><h2>First Topic</h2></AnimatedSection>
<p>2,000+ words of expert-level content...</p>
</section>
<InlineCta heading="Ready to try PRODUCT?" href="/waitlist" label="Join Waitlist" />
<section id="section-2">
<AnimatedSection><h2>Second Topic</h2></AnimatedSection>
<StepTimeline steps={[{ title: "Step 1", description: "..." }]} />
</section>
<FaqSection items={[{ question: "Q?", answer: "A." }]} />
</article>
<StickyBottomCta heading="Try PRODUCT free" href="/waitlist" label="Join Waitlist" />
</>
);
}
src/app/t/page.tsximport { discoverGuides } from "@seo/components/server";
import { Breadcrumbs, AnimatedSection, breadcrumbListSchema } from "@seo/components";
export const metadata = { title: "Guides | PRODUCT", description: "..." };
export default function GuidesIndex() {
const guides = discoverGuides();
const jsonLd = breadcrumbListSchema([
{ name: "Home", url: "https://DOMAIN" },
{ name: "Guides", url: "https://DOMAIN/t" },
]);
return (
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<div className="max-w-4xl mx-auto px-6 py-16">
<Breadcrumbs items={[{ label: "Home", href: "/" }, { label: "Guides" }]} className="mb-8" />
<h1 className="text-4xl font-bold mb-8">Guides</h1>
<div className="grid gap-4">
{guides.map((guide, i) => (
<AnimatedSection key={guide.slug} delay={i * 0.03}>
<a href={guide.href} className="block p-6 rounded-xl border border-gray-100 hover:shadow-md transition-shadow">
<h2 className="text-xl font-semibold">{guide.title}</h2>
{guide.description && <p className="text-gray-600 mt-2">{guide.description}</p>}
</a>
</AnimatedSection>
))}
</div>
</div>
</>
);
}
// src/components/site-sidebar.tsx (server component)
import { walkPages } from "@seo/components/server";
import { SitemapSidebarClient } from "./site-sidebar-client";
export function SiteSidebar() {
const pages = walkPages({ excludePaths: ["api"] });
return <SitemapSidebarClient pages={pages} />;
}
// src/components/site-sidebar-client.tsx (client component)
"use client";
import { SitemapSidebar } from "@seo/components";
export function SitemapSidebarClient({ pages }: { pages: any[] }) {
return <SitemapSidebar items={pages} />;
}
Create src/app/api/guide-chat/route.ts:
import { createGuideChatHandler } from "@seo/components/server";
export const POST = createGuideChatHandler({
app: "PRODUCT_SLUG",
brand: "PRODUCT_NAME",
siteDescription: "Brief description of the product for AI context.",
contentDir: "src/app/t",
});
Then add <GuideChatPanel /> from @seo/components to guide pages or the layout.
JSON-LD helpers: breadcrumbListSchema, faqPageSchema, articleSchema, howToSchema
Content components: Breadcrumbs, ArticleMeta, FaqSection, ComparisonTable, ProofBand, ProofBanner, InlineTestimonial
Animation components: AnimatedBeam, MorphingText, NumberTicker, OrbitingCircles, Particles, Marquee, ShimmerButton, GradientText, TextShimmer, TypingAnimation, ShineBorder, BackgroundGrid
Layout components: BentoGrid, BeforeAfter, AnimatedDemo, GlowCard, ParallaxSection, StepTimeline, MotionSequence, AnimatedSection, AnimatedMetric, MetricsRow
Code/technical display: AnimatedCodeBlock, CodeComparison, TerminalOutput, FlowDiagram, SequenceDiagram, AnimatedChecklist
CTA components: InlineCta, StickyBottomCta
Interactive: SitemapSidebar, HeadingAnchors, GuideChatPanel
Video/animation: RemotionClip, LottiePlayer
next.config.ts has standalone outputimport type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
FROM node:20-alpine AS base
# --- Dependencies ---
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --ignore-scripts
# --- Builder ---
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* vars must be present at build time because Next.js bakes them
# into the client bundle. Runtime env vars on Cloud Run are too late.
ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY
ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# --- Runner ---
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
# Cloud Run sets PORT env var (default 8080)
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"
EXPOSE 8080
CMD ["node", "server.js"]
Add any additional NEXT_PUBLIC_* build args if the project uses them (e.g., Firebase config). Runtime-only secrets (RESEND_API_KEY, DATABASE_URL) are set on the Cloud Run service directly.
git init && git add -A && git commit -m "Initial landing page"
gh repo create YOUR_GITHUB_ORG/PROJECT_NAME --private --source=. --push
# Create project (or reuse existing)
gcloud projects create GCP_PROJECT_ID --name="PROJECT_NAME" --organization=ORG_ID
gcloud billing projects link GCP_PROJECT_ID --billing-account=BILLING_ACCOUNT
# Enable required APIs
gcloud services enable run.googleapis.com \
dns.googleapis.com \
compute.googleapis.com \
certificatemanager.googleapis.com \
--project=GCP_PROJECT_ID
# Create Cloud DNS zone for the domain
gcloud dns managed-zones create DNS_ZONE \
--dns-name="DOMAIN." \
--description="DNS zone for DOMAIN" \
--project=GCP_PROJECT_ID
# Get nameservers and update your domain registrar to point to them
gcloud dns managed-zones describe DNS_ZONE --project=GCP_PROJECT_ID --format="value(nameServers)"
# First deploy (source-based, Cloud Build builds the container automatically)
gcloud run deploy SERVICE_NAME \
--source . \
--region us-central1 \
--project GCP_PROJECT_ID \
--allow-unauthenticated \
--set-env-vars "RESEND_API_KEY=KEY" \
--set-env-vars "RESEND_AUDIENCE_ID=ID" \
--set-env-vars "DATABASE_URL=postgresql://..." \
--quiet
The --source . flag uses Cloud Build to build the Dockerfile and push to Artifact Registry automatically.
# Reserve a static IP
gcloud compute addresses create PROJECT_NAME-ip --global --project=GCP_PROJECT_ID
STATIC_IP=$(gcloud compute addresses describe PROJECT_NAME-ip --global --project=GCP_PROJECT_ID --format="value(address)")
# Create DNS A record pointing domain to the static IP
gcloud dns record-sets create DOMAIN. --type=A --ttl=300 \
--rrdatas="$STATIC_IP" --zone=DNS_ZONE --project=GCP_PROJECT_ID
# Create Certificate Manager DNS authorization
gcloud certificate-manager dns-authorizations create PROJECT_NAME-dns-auth \
--domain="DOMAIN" --project=GCP_PROJECT_ID
# Get the CNAME record for DNS authorization and add it
AUTH_CNAME=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.name)")
AUTH_DATA=$(gcloud certificate-manager dns-authorizations describe PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID --format="value(dnsResourceRecord.data)")
gcloud dns record-sets create "${AUTH_CNAME}." --type=CNAME --ttl=300 \
--rrdatas="${AUTH_DATA}." --zone=DNS_ZONE --project=GCP_PROJECT_ID
# Create managed SSL certificate
gcloud certificate-manager certificates create PROJECT_NAME-cert \
--domains="DOMAIN" \
--dns-authorizations=PROJECT_NAME-dns-auth \
--project=GCP_PROJECT_ID
# Create certificate map and entry
gcloud certificate-manager maps create PROJECT_NAME-cert-map --project=GCP_PROJECT_ID
gcloud certificate-manager maps entries create PROJECT_NAME-cert-entry \
--map=PROJECT_NAME-cert-map \
--certificates=PROJECT_NAME-cert \
--hostname="DOMAIN" \
--project=GCP_PROJECT_ID
# Create serverless NEG pointing to Cloud Run
gcloud compute network-endpoint-groups create PROJECT_NAME-neg \
--region=us-central1 \
--network-endpoint-type=serverless \
--cloud-run-service=SERVICE_NAME \
--project=GCP_PROJECT_ID
# Create backend service
gcloud compute backend-services create PROJECT_NAME-backend \
--global --project=GCP_PROJECT_ID
gcloud compute backend-services add-backend PROJECT_NAME-backend \
--global \
--network-endpoint-group=PROJECT_NAME-neg \
--network-endpoint-group-region=us-central1 \
--project=GCP_PROJECT_ID
# Create URL map and HTTPS proxy
gcloud compute url-maps create PROJECT_NAME-urlmap \
--default-service=PROJECT_NAME-backend \
--global --project=GCP_PROJECT_ID
gcloud compute target-https-proxies create PROJECT_NAME-https-proxy \
--url-map=PROJECT_NAME-urlmap \
--certificate-map=PROJECT_NAME-cert-map \
--global --project=GCP_PROJECT_ID
# Create forwarding rule
gcloud compute forwarding-rules create PROJECT_NAME-https-rule \
--global \
--target-https-proxy=PROJECT_NAME-https-proxy \
--address=PROJECT_NAME-ip \
--ports=443 \
--project=GCP_PROJECT_ID
# Optional: HTTP to HTTPS redirect
gcloud compute url-maps import PROJECT_NAME-http-redirect --global --project=GCP_PROJECT_ID <<EOF
name: PROJECT_NAME-http-redirect
defaultUrlRedirect:
httpsRedirect: true
redirectResponseCode: MOVED_PERMANENTLY_DEFAULT
EOF
gcloud compute target-http-proxies create PROJECT_NAME-http-proxy \
--url-map=PROJECT_NAME-http-redirect \
--global --project=GCP_PROJECT_ID
gcloud compute forwarding-rules create PROJECT_NAME-http-rule \
--global \
--target-http-proxy=PROJECT_NAME-http-proxy \
--address=PROJECT_NAME-ip \
--ports=80 \
--project=GCP_PROJECT_ID
# Set Cloud Run ingress to internal + LB only (blocks direct .run.app access)
gcloud run services update SERVICE_NAME \
--ingress=internal-and-cloud-load-balancing \
--region=us-central1 \
--project=GCP_PROJECT_ID
Create .github/workflows/deploy-cloudrun.yml:
name: Deploy to Cloud Run
on:
push:
branches: [main]
workflow_dispatch:
env:
REGION: us-central1
jobs:
deploy:
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: read
concurrency:
group: deploy-production
cancel-in-progress: false
steps:
- uses: actions/checkout@v4
- id: auth
uses: google-github-actions/auth@v2
with:
credentials_json: ${{ secrets.GCP_SA_KEY }}
- uses: google-github-actions/setup-gcloud@v2
- name: Deploy to Cloud Run
run: |
gcloud run deploy SERVICE_NAME \
--source . \
--region ${{ env.REGION }} \
--project ${{ secrets.GCP_PROJECT_ID }} \
--quiet
- name: Verify deployment
run: |
PUBLIC=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "https://DOMAIN/" || echo "000")
echo "Public health (https://DOMAIN/): HTTP $PUBLIC"
if [ "$PUBLIC" != "200" ]; then
echo "::warning::Public health check returned $PUBLIC"
fi
GitHub repo secrets to add:
GCP_SA_KEY: service account JSON key with Cloud Run Admin, Cloud Build Editor, and Storage Admin rolesGCP_PROJECT_ID: the GCP project IDgcloud run deploy SERVICE_NAME --source . --region us-central1 --project GCP_PROJECT_ID --quiet
gcloud dns record-sets create DOMAIN. --type=TXT --ttl=300 --rrdatas='"google-site-verification=..."' --zone=DNS_ZONE --project=GCP_PROJECT_IDsrc/app/sitemap.ts as a dynamic filesystem walker. Do not hardcode a URL array — that pattern drifts silently the moment anyone adds a page and forgets to register it. We learned this the hard way on assrt-website where 171 of 306 pages (56%) were missing from the sitemap because the pipeline kept forgetting to update the hardcoded array.The walker recursively scans src/app/ for every page.tsx, strips route groups ((main), (auth)), skips api/, private folders (_*), and dynamic segments ([slug], [...slug]). Every new static page added anywhere under src/app/ automatically appears in the sitemap on next build. Zero registration step, zero drift.
Copy this into src/app/sitemap.ts exactly:
import type { MetadataRoute } from "next";
import fs from "node:fs";
import path from "node:path";
const BASE_URL = "https://DOMAIN";
const APP_DIR = path.join(process.cwd(), "src/app");
type SitemapEntry = { url: string; lastModified: Date };
function walkPages(dir: string, urlSegments: string[] = []): SitemapEntry[] {
const results: SitemapEntry[] = [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return results;
}
for (const entry of entries) {
if (entry.isFile() && entry.name === "page.tsx") {
const filePath = path.join(dir, entry.name);
const stat = fs.statSync(filePath);
const urlPath = urlSegments.length === 0 ? "" : "/" + urlSegments.join("/");
results.push({
url: `${BASE_URL}${urlPath}`,
lastModified: stat.mtime,
});
continue;
}
if (!entry.isDirectory()) continue;
const name = entry.name;
// Private folders (not routable)
if (name.startsWith("_")) continue;
// API routes don't belong in a sitemap
if (name === "api") continue;
// Dynamic segments — can't be enumerated without a data source.
// Handle them explicitly below if you have one.
if (name.startsWith("[") && name.endsWith("]")) continue;
// Route groups are included in the walk but do NOT contribute to the URL path
const isRouteGroup = name.startsWith("(") && name.endsWith(")");
const nextSegments = isRouteGroup ? urlSegments : [...urlSegments, name];
results.push(...walkPages(path.join(dir, entry.name), nextSegments));
}
return results;
}
export default function sitemap(): MetadataRoute.Sitemap {
const filesystemPages = walkPages(APP_DIR);
// If you have dynamic routes (e.g. [slug], [...slug]), expand them from their
// data source here and concat onto filesystemPages. Examples:
//
// // Blog posts from content/blog/*.mdx:
// const blogPages = fs.readdirSync(path.join(process.cwd(), "content/blog"))
// .filter((f) => f.endsWith(".mdx"))
// .map((f) => ({
// url: `${BASE_URL}/blog/${f.replace(/\.mdx$/, "")}`,
// lastModified: fs.statSync(path.join(process.cwd(), "content/blog", f)).mtime,
// }));
//
// // Docs from a nav config:
// import { docsNav, slugToPath } from "./(main)/docs/nav-config";
// const docsPages = docsNav.flatMap((g) => g.items).map((item) => ({
// url: `${BASE_URL}${slugToPath(item.slug)}`,
// lastModified: new Date(),
// }));
// Dedupe by URL so a dynamic expansion can't collide with a static page
const seen = new Set<string>();
const all: SitemapEntry[] = [];
for (const entry of filesystemPages) {
if (seen.has(entry.url)) continue;
seen.add(entry.url);
all.push(entry);
}
return all;
}
Design rules (follow these, don't re-derive them):
fs.readdirSync at build time is free; the penalty is paid once per deploy, not per request.(main), (marketing), (auth), etc.) from the URL path. Include them in the walk.api/, private folders starting with _, and dynamic segments [slug] / [...slug]. API routes are not pages; dynamic segments need an explicit data source./docs/index from the walk plus /docs/getting-started from a nav config).mtime for lastModified. It's free, honest, and Google treats it as a hint anyway.Test the sitemap before deploying:
npx next build
# Then count the URLs in the generated sitemap body:
grep -c '<loc>' .next/server/app/sitemap.xml.body
# Spot-check that all expected static pages are present:
grep -oE '<loc>[^<]+</loc>' .next/server/app/sitemap.xml.body | head
If the URL count is lower than the number of page.tsx files on disk (minus dynamic segments), something is wrong with the walker.
https://DOMAIN/sitemap.xmlAdd the new domain to the unified analytics dashboard at ~/analytics-dashboard/.
src/lib/config.ts — add a new entry to the DOMAINS array:{
slug: "my-app", // kebab-case domain (used in URL paths)
domain: "myapp.com", // bare domain
label: "MyApp", // display name
posthog: { projectId: "PROJECT_ID", host: "us" }, // "us" or "eu"
gscProperty: "sc-domain:myapp.com",
// If the project has a waitlist with PostHog tracking, add:
customEvents: [{ event: "waitlist_signup", label: "Waitlist Signups" }],
// If the project uses Resend for waitlist/audience, add:
resend: { audienceId: "AUDIENCE_ID" }, // get ID from: curl https://api.resend.com/audiences -H "Authorization: Bearer $RESEND_API_KEY"
},
cd ~/analytics-dashboard
git add src/lib/config.ts
git commit -m "Add PROJECT_NAME to analytics dashboard"
git push
https://DOMAINgmail skill or Resend Sending tab){product}_emails table (direction='outbound')$pageview and waitlist_signup eventsmatt@DOMAIN, confirm it appears in Resend Receiving tab{product}_emails table (direction='inbound')[PRODUCT Inbound] email arrives at [email protected]sitemap.ts is the dynamic filesystem walker (not a hardcoded URL array)grep -c '<loc>' .next/server/app/sitemap.xml.body matches the number of routable page.tsx files on disk[slug], [...slug]) have an explicit expansion block in sitemap.ts@seo/components installed and transpilePackages configured in next.config.ts/t lists all discovered guides/t/{slug} render with Breadcrumbs, structured data, and CTA componentsid attributes on H2 elements (inspect DOM)<head> (check page source).next/seo-guides-manifest.json with correct page countdevelopment
Maintainer-only workflow for handling GitHub Secret Scanning alerts on OpenClaw. Use when Codex needs to triage, redact, clean up, and resolve secret leakage found in issue comments, issue bodies, PR comments, or other GitHub content.
development
Maintainer workflow for OpenClaw releases, prereleases, changelog release notes, and publish validation. Use when Codex needs to prepare or verify stable or beta release steps, align version naming, assemble release notes, check release auth requirements, or validate publish-time commands and artifacts.
development
Run, watch, debug, and extend OpenClaw QA testing with qa-lab and qa-channel. Use when Codex needs to execute the repo-backed QA suite, inspect live QA artifacts, debug failing scenarios, add new QA scenarios, or explain the OpenClaw QA workflow. Prefer the live OpenAI lane with regular openai/gpt-5.4 in fast mode; do not use gpt-5.4-pro or gpt-5.4-mini unless the user explicitly overrides that policy.
development
End-to-end Parallels smoke, upgrade, and rerun workflow for OpenClaw across macOS, Windows, and Linux guests. Use when Codex needs to run, rerun, debug, or interpret VM-based install, onboarding, gateway smoke tests, latest-release-to-main upgrade checks, fresh snapshot retests, or optional Discord roundtrip verification under Parallels.