New Product Website
Automates the full launch workflow for a new product landing page: scaffold Next.js app, configure theme, wire analytics + waitlist, deploy to Vercel, configure domain, and register with Google Search Console.
Arguments
Provide the product name, domain, and a brief description. Example: "MyApp at myapp.com — AI-powered task management"
Prerequisites
- Vercel account with a configured scope/team
- Domain purchased (Vercel Domains or external with Vercel nameservers)
- PostHog account (US or EU instance)
- Resend account for transactional/waitlist emails (shared account across projects)
- Neon Postgres database for email storage
- Google Search Console access
- GitHub org or personal account
Stack
- Next.js 15 (App Router) + React 19 + TypeScript
- Tailwind CSS 3.4 + Framer Motion
- PostHog for analytics
- Resend for outbound emails, inbound receiving, and waitlist
- Neon Postgres for email storage (
{product}_emailstable) - Vercel for hosting
- Google Search Console for SEO
Workflow
1. Scaffold Project
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 | Empty config |
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 |
2. Build Sections
Standard landing page sections (adapt content per product):
- Navbar — Sticky, backdrop blur, logo + nav links + CTA button
- Hero — Headline + subheadline + terminal/demo animation + waitlist CTA
- Stats — Animated counters with real metrics
- How It Works — 3-step flow with icons
- Results/Social Proof — Real examples styled as platform cards
- Features — 6-card grid with icons
- FAQ — Accordion with AnimatePresence
- CTA — Email capture form + "No spam" disclaimer
- Footer — Logo + nav links
3. Wire PostHog
- Log in to your PostHog instance
- Create new project named after the product
- Copy the project API key (starts with
phc_) - Note the PostHog host URL (e.g.,
https://us.i.posthog.comorhttps://eu.i.posthog.com) - Create
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 };
- Wrap children in
layout.tsxwith<PostHogProvider>
4. Wire Resend (Domain + Waitlist + Inbound)
4a. Add & Verify Domain in Resend
- Log in to Resend > Domains > Add domain > enter
DOMAIN - Resend will show DNS records (DKIM TXT, SPF MX + TXT). Add them via Vercel CLI:
# DKIM
vercel dns add DOMAIN 'resend._domainkey' TXT 'DKIM_VALUE' --scope YOUR_VERCEL_SCOPE
# SPF
vercel dns add DOMAIN 'send' MX 'feedback-smtp.us-east-1.amazonses.com' 10 --scope YOUR_VERCEL_SCOPE
vercel dns add DOMAIN 'send' TXT 'v=spf1 include:amazonses.com ~all' --scope YOUR_VERCEL_SCOPE
# DMARC (deliverability + anti-spoofing)
vercel dns add DOMAIN '_dmarc' TXT 'v=DMARC1; p=none;' --scope YOUR_VERCEL_SCOPE
- Wait for Resend to verify (usually < 5 min)
4b. Enable Inbound Receiving
- In Resend > Domains > click your domain > Records tab
- Toggle "Enable Receiving" ON
- Resend shows an MX record for
@. Add it:
vercel dns add DOMAIN '' MX 'inbound-smtp.us-east-1.amazonaws.com' 10 --scope YOUR_VERCEL_SCOPE
- Verify with
dig MX DOMAIN +short— should show10 inbound-smtp.us-east-1.amazonaws.com. - Wait for Resend to verify the MX record
4c. Create Email Storage Table
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);
4d. Create Inbound Webhook
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: "YOUR_EMAIL",
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.
4e. Register Webhook with Resend
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"]}'
4f. Create Waitlist/Audience
- In Resend > Audience > create "{Product} Waitlist"
- Copy the audience ID
- Create
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 });
}
}
- CTA form calls
/api/waitlist+ firesposthog?.capture("waitlist_signup", { email })
5. Deploy to Vercel
# Init git and push to GitHub
git init && git add -A && git commit -m "Initial landing page"
gh repo create YOUR_ORG/PROJECT_NAME --private --source=. --push
# Deploy to Vercel
npx vercel --yes --scope YOUR_VERCEL_SCOPE
# Add domain
npx vercel domains add DOMAIN --scope YOUR_VERCEL_SCOPE
# Set env vars (production)
npx vercel env add NEXT_PUBLIC_POSTHOG_KEY production --scope YOUR_VERCEL_SCOPE <<< "KEY"
npx vercel env add NEXT_PUBLIC_POSTHOG_HOST production --scope YOUR_VERCEL_SCOPE <<< "https://us.i.posthog.com"
npx vercel env add RESEND_API_KEY production --scope YOUR_VERCEL_SCOPE <<< "KEY"
npx vercel env add RESEND_AUDIENCE_ID production --scope YOUR_VERCEL_SCOPE <<< "ID"
npx vercel env add DATABASE_URL production --scope YOUR_VERCEL_SCOPE <<< "postgresql://..."
# Production deploy
npx vercel --prod --scope YOUR_VERCEL_SCOPE
6. Google Search Console
- Navigate to https://search.google.com/search-console
- Add property > Domain > enter domain
- Copy the TXT verification record
- Add via Vercel DNS:
npx vercel dns add DOMAIN @ TXT "google-site-verification=..." --scope YOUR_VERCEL_SCOPE - Click Verify
- Submit sitemap: create
src/app/sitemap.ts:
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: "https://DOMAIN",
lastModified: new Date(),
changeFrequency: "weekly",
priority: 1,
},
];
}
- In Search Console > Sitemaps, submit
https://DOMAIN/sitemap.xml
7. Add to Analytics Dashboard
Add the new domain to the unified analytics dashboard at ~/analytics-dashboard/.
- Edit
src/lib/config.ts— add a new entry to theDOMAINSarray:
{
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"
},
- Commit, push, and deploy:
cd ~/analytics-dashboard
git add src/lib/config.ts
git commit -m "Add PROJECT_NAME to analytics dashboard"
git push
npx vercel --prod --scope YOUR_VERCEL_SCOPE
Dashboard URL: your analytics dashboard URL
8. Verify Everything
- Site loads at
https://DOMAIN - Waitlist form submits and shows success
- Welcome email received (check with
gmailskill or Resend Sending tab) - Email appears in Resend audience
- Outbound email logged in
{product}_emailstable (direction='outbound') - PostHog captures
$pageviewandwaitlist_signupevents - Resend domain fully verified (DKIM + SPF + DMARC + MX inbound)
- Inbound test: send email to
matt@DOMAIN, confirm it appears in Resend Receiving tab - Inbound webhook: confirm email stored in
{product}_emailstable (direction='inbound') - Inbound forwarding: confirm
[PRODUCT Inbound]email arrives atYOUR_EMAIL - Domain filter: confirm webhook ignores emails to other domains on the shared Resend account
- Google Search Console shows "Ownership verified"
- Sitemap is submitted (may show "Couldn't fetch" initially — normal)