packages/skills/skills/new-product-website/SKILL.md
End-to-end workflow for launching a new product landing page — scaffolding, theming, analytics, waitlist, domain, SEO, and deployment.
npx skillsauth add mediar-ai/skillhubz 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 counttools
# X Twitter Scraper Use Xquik for X/Twitter tweet search, user lookup, profile tweets, follower export, media download, monitors, webhooks, posting workflows, and MCP-backed API exploration. ## Prerequisites - A Xquik API key in `XQUIK_API_KEY`. - Internet access to `https://xquik.com/api/v1`, `https://xquik.com/mcp`, and `https://docs.xquik.com`. - A clear user request that identifies the target tweets, users, accounts, keywords, media, monitor, webhook, or write action. ## Source Truth -
tools
Use when the user says "mk0r", "appmaker CLI", "open a VM", "run something in the sandbox", "talk to the VM agent", "spin up an E2B sandbox", or "chat with appmaker from CLI." Wraps the `mk0r` CLI to list projects, exec commands inside their E2B sandboxes, stream chat with the VM agent (same `/api/chat` the web UI uses), toggle SOAX residential IP, manage schedules, and copy files. Supports a sticky default project via `mk0r projects use`.
testing
Use when the user mentions "influencer candidates", "social media operator", "check proposals on Upwork/Fiverr", "review influencer applications", "qualify candidates", or "reach out to operators". Manages the IG/TikTok account operator hiring pipeline — review applicants, check replies, qualify, and do proactive outreach.
tools
End-to-end newsletter pipeline: investigate recent features, draft, send via API endpoint, and track delivery/open/click metrics.