Research covering Next.js App Router patterns, Nodemailer SMTP contact form, pure-React testimonials carousel, honeypot spam protection, and React 19 useActionState API for the public marketing homepage phase. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
Phase 2: Marketing Site - Research
Researched: 2026-03-19 Domain: Next.js 16 App Router — public marketing page, Nodemailer SMTP contact form, carousel UI Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
Hero section
- Layout: Split panel — photo on the left, text content on the right (mirrors the login page feel)
- Tone: Professional and polished — credentials, experience, trust-building (not casual)
- CTA button: "Get in Touch" — smooth-scrolls to the contact form section
- Copy: Claude writes the headline, subheading, and bio (professional Utah real estate agent framing). Wrap text in clearly commented placeholders so Teressa can swap in her own words before launch
Testimonials
- Display: Auto-scrolling carousel — rotates every 5 seconds, pauses on hover
- Navigation: Arrow controls and dot indicators so visitors can also advance manually
- Content: Claude writes 4–5 sample testimonials with realistic Utah buyer/seller scenarios and placeholder full names (e.g., "Sarah Mitchell"). Clearly commented so real ones can be swapped in
- Attribution: Quote + full name only (no city, no photo)
Contact form
- Delivery: Email notification to Teressa via Nodemailer + SMTP (no database storage in this phase)
- SMTP credentials:
CONTACT_EMAIL_USERandCONTACT_EMAIL_PASSenvironment variables — never hardcoded - Success state: Inline — the form is replaced by a thank-you message ("Thanks! Teressa will be in touch soon.") when submission succeeds
- Spam protection: Honeypot hidden field — bots fill it in, humans never see it; submissions with the honeypot filled are silently discarded
- Validation: Required fields are name, email, phone, and message — validated server-side in the form action
Page structure
- Section order (top to bottom): Sticky nav → Hero → Testimonials → Listings placeholder → Contact form → Footer
- Sticky nav content: Teressa's name as wordmark logo (left), anchor links on the right — Home, About, Listings, Contact — smooth-scroll to each section
- Listings placeholder: A tasteful "Listings Coming Soon" section with a brief description — no real data, just visual presence
- Agent portal: No link anywhere on the public site — Teressa navigates to
/agent/logindirectly via URL - Footer: Name + Utah real estate license number (
14196185-SA00— NOTE: verify the last two characters, may be letters O or zeros) + copyright year + repeated nav links
Placeholder assets
- Hero photo: reuse
public/red.jpgfrom Phase 1 (already in the project) - Listings placeholder: use a tasteful stock-style image or a solid brand-colored background with an icon — no real property photos needed
- Background section breaks: subtle texture or light gradient using brand cream/navy — no photography required for non-hero sections
- Icons: use Lucide React (already likely available) for any UI icons (phone, email, map pin for contact section)
Claude's Discretion
- Exact hero photo crop/focal point and overlay gradient (if any)
- Mobile nav pattern (hamburger menu vs collapsed links)
- Exact card styling, shadow depth, border radius
- Animation timing beyond the 5-second carousel interval
- Exact color weights within the brand palette already established (navy
#1B2B4B, gold#C9A84C, cream#FAF9F7)
Deferred Ideas (OUT OF SCOPE)
- None — discussion stayed within phase scope </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| MKTG-01 | Visitor sees a hero section with Teressa's professional photo and warm introductory bio | Split-panel layout pattern, Next.js Image component, public/red.jpg already in project |
| MKTG-02 | Visitor sees a "listings coming soon" placeholder section (full WFRMLS integration deferred to v2) | Static section component, brand color background + Lucide icon |
| MKTG-03 | Visitor can submit a contact form with name, email, phone, and message | Next.js Server Action, Nodemailer + SMTP, honeypot pattern, server-side Zod validation |
| MKTG-04 | Visitor sees a testimonials section with client reviews on the homepage | Pure React carousel (useState + useEffect interval), no external carousel library needed |
| </phase_requirements> |
Summary
This phase builds a single public-facing homepage for Teressa Copeland Homes on top of the Next.js 16 + Tailwind CSS 4 stack established in Phase 1. The page is a single route (app/page.tsx) composed of full-width section components: sticky nav, hero, testimonials carousel, listings placeholder, contact form, and footer. No new major dependencies are required — Nodemailer is the only addition for contact form email delivery.
The carousel can be built with zero external libraries using React useState / useEffect — a 5-second auto-advance with hover-pause, arrow buttons, and dot indicators is straightforward in ~60 lines of client component code. Reaching for a carousel library like Embla or Swiper would add unnecessary bundle weight for a single simple use case.
The contact form uses a Next.js Server Action (no API route needed) with Zod validation already in the project, Nodemailer for SMTP delivery, and a honeypot hidden field for bot protection. No database writes occur in this phase — the form is fire-and-forget email only.
Primary recommendation: Build everything as Next.js App Router server components with minimal client component islands (carousel, mobile nav toggle, form state). Keep the public page route at app/page.tsx and colocate section components under app/_components/ or components/marketing/.
Standard Stack
Core (already in project — no new installs for UI)
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Next.js | 16.2.0 | App Router, Server Actions, Image optimization | Already installed; Phase 1 foundation |
| React | 19.2.4 | Component model | Already installed |
| Tailwind CSS | ^4 | Utility-first styling | Already installed; brand colors via inline styles or CSS vars |
| Zod | ^4.3.6 | Server-side form validation | Already installed; used in Phase 1 auth |
New Dependency — Contact Form Email
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| nodemailer | ^6.9.x | SMTP email delivery from Server Action | De facto Node.js SMTP library; well-maintained; zero vendor lock-in |
| @types/nodemailer | ^6.4.x | TypeScript types | Required for TS strict mode |
Supporting (already available)
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| lucide-react | latest | Icon set (phone, mail, map-pin, chevron, etc.) | Confirmed in CONTEXT.md; check if installed or add |
Check if lucide-react is already installed:
grep lucide /Users/ccopeland/temp/red/teressa-copeland-homes/package.json
If absent, add it.
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Hand-rolled carousel | Embla Carousel or Swiper | Overkill for a single 4-5 item testimonials carousel; adds bundle weight |
| Nodemailer | Resend SDK, SendGrid | Adds vendor dependency; Nodemailer works with any SMTP provider already owned by Teressa |
| Server Action form | API Route (app/api/contact/route.ts) |
Server Actions are simpler, no fetch boilerplate; use progressive enhancement |
Installation (new packages only):
cd /Users/ccopeland/temp/red/teressa-copeland-homes
npm install nodemailer
npm install --save-dev @types/nodemailer
# If lucide-react not present:
npm install lucide-react
Architecture Patterns
Recommended Project Structure
teressa-copeland-homes/
├── app/
│ ├── page.tsx # Public homepage — server component, composes sections
│ ├── layout.tsx # Root layout (already exists from Phase 1)
│ └── _components/ # Marketing page section components (colocated)
│ ├── SiteNav.tsx # Sticky nav — client component (mobile toggle state)
│ ├── HeroSection.tsx # Server component
│ ├── TestimonialsSection.tsx # Client component (carousel state + interval)
│ ├── ListingsPlaceholder.tsx # Server component
│ ├── ContactSection.tsx # Client component (form state, submission)
│ └── SiteFooter.tsx # Server component
├── lib/
│ ├── contact-mailer.ts # Nodemailer transporter + sendContactEmail()
│ └── contact-action.ts # Server Action — validates, calls mailer, returns state
└── public/
└── red.jpg # Hero photo (already exists)
Why app/_components/: Next.js treats files prefixed with _ as private — they're not treated as routes. Keeps section components colocated with the page without polluting the route tree.
Alternative: components/marketing/ at project root. Either works; pick one and be consistent.
Pattern 1: Server Action for Contact Form (No API Route)
What: Form submits to a Server Action; action validates with Zod, sends email via Nodemailer, returns typed state. When to use: Any form in Next.js App Router that doesn't need a public REST endpoint.
// lib/contact-action.ts
'use server'
import { z } from 'zod'
import { sendContactEmail } from './contact-mailer'
const ContactSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Valid email required'),
phone: z.string().min(7, 'Phone is required'),
message: z.string().min(10, 'Message is required'),
// Honeypot — must be empty
website: z.string().max(0),
})
export type ContactState = { status: 'idle' | 'success' | 'error'; message?: string }
export async function submitContact(
_prev: ContactState,
formData: FormData,
): Promise<ContactState> {
const raw = {
name: formData.get('name'),
email: formData.get('email'),
phone: formData.get('phone'),
message: formData.get('message'),
website: formData.get('website'), // honeypot
}
const parsed = ContactSchema.safeParse(raw)
if (!parsed.success) {
return { status: 'error', message: 'Please fill in all required fields.' }
}
// Honeypot filled — silently succeed (don't tell the bot it failed)
if (parsed.data.website !== '') {
return { status: 'success' }
}
try {
await sendContactEmail(parsed.data)
return { status: 'success' }
} catch {
return { status: 'error', message: 'Something went wrong. Please try again.' }
}
}
Pattern 2: Nodemailer SMTP Transporter
What: Singleton transporter initialized from env vars; called from Server Action. When to use: Any server-side email send in Node.js/Next.js.
// lib/contact-mailer.ts
import nodemailer from 'nodemailer'
const transporter = nodemailer.createTransport({
host: process.env.CONTACT_SMTP_HOST, // e.g., smtp.gmail.com
port: Number(process.env.CONTACT_SMTP_PORT ?? 587),
secure: false, // true for 465, false for 587 (STARTTLS)
auth: {
user: process.env.CONTACT_EMAIL_USER,
pass: process.env.CONTACT_EMAIL_PASS,
},
})
export async function sendContactEmail(data: {
name: string
email: string
phone: string
message: string
}) {
await transporter.sendMail({
from: `"${data.name}" <${process.env.CONTACT_EMAIL_USER}>`,
replyTo: data.email,
to: process.env.CONTACT_EMAIL_USER, // Teressa's inbox
subject: `New contact from ${data.name} — Teressa Copeland Homes`,
text: `Name: ${data.name}\nEmail: ${data.email}\nPhone: ${data.phone}\n\n${data.message}`,
html: `<p><strong>Name:</strong> ${data.name}</p>
<p><strong>Email:</strong> ${data.email}</p>
<p><strong>Phone:</strong> ${data.phone}</p>
<p><strong>Message:</strong><br/>${data.message.replace(/\n/g, '<br/>')}</p>`,
})
}
Pattern 3: Pure React Testimonials Carousel
What: Client component with useState (activeIndex), useEffect (5-second interval), pause on hover.
When to use: Simple auto-advance carousel with ≤ 10 items — no library needed.
// app/_components/TestimonialsSection.tsx
'use client'
import { useState, useEffect, useRef } from 'react'
import { ChevronLeft, ChevronRight } from 'lucide-react'
const TESTIMONIALS = [
// PLACEHOLDER — replace with real client testimonials before launch
{ quote: "Working with Teressa made buying our first home in Salt Lake County seamless...", name: "Sarah Mitchell" },
{ quote: "Teressa sold our Provo home in under a week above asking price...", name: "James & Karen Olsen" },
// add 3-5 total
]
export function TestimonialsSection() {
const [active, setActive] = useState(0)
const [paused, setPaused] = useState(false)
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
useEffect(() => {
if (paused) return
intervalRef.current = setInterval(() => {
setActive(i => (i + 1) % TESTIMONIALS.length)
}, 5000)
return () => { if (intervalRef.current) clearInterval(intervalRef.current) }
}, [paused])
const prev = () => setActive(i => (i - 1 + TESTIMONIALS.length) % TESTIMONIALS.length)
const next = () => setActive(i => (i + 1) % TESTIMONIALS.length)
return (
<section
id="about"
onMouseEnter={() => setPaused(true)}
onMouseLeave={() => setPaused(false)}
>
{/* carousel content */}
<blockquote>"{TESTIMONIALS[active].quote}"</blockquote>
<p>— {TESTIMONIALS[active].name}</p>
<button onClick={prev}><ChevronLeft /></button>
<button onClick={next}><ChevronRight /></button>
<div>
{TESTIMONIALS.map((_, i) => (
<button key={i} onClick={() => setActive(i)} aria-label={`Testimonial ${i + 1}`}
style={{ opacity: i === active ? 1 : 0.3 }} />
))}
</div>
</section>
)
}
Pattern 4: Smooth-Scroll Anchor Navigation
What: Pure CSS + HTML id anchors; no JavaScript scroll library needed.
When to use: Single-page anchor navigation.
// CSS approach — add to globals.css
// html { scroll-behavior: smooth; }
// Nav links
<a href="#hero">Home</a>
<a href="#about">About</a>
<a href="#listings">Listings</a>
<a href="#contact">Contact</a>
// Section targets
<section id="hero"> ... </section>
<section id="about"> ... </section> // testimonials anchors here per section order
<section id="listings"> ... </section>
<section id="contact"> ... </section>
Pattern 5: Contact Form with useActionState (React 19)
What: React 19's useActionState replaces useFormState from react-dom/server. Works with Server Actions in Next.js App Router.
'use client'
import { useActionState } from 'react'
import { submitContact, type ContactState } from '@/lib/contact-action'
const initialState: ContactState = { status: 'idle' }
export function ContactForm() {
const [state, action, pending] = useActionState(submitContact, initialState)
if (state.status === 'success') {
return <p>Thanks! Teressa will be in touch soon.</p>
}
return (
<form action={action}>
{/* Honeypot — visually hidden, bots fill it, humans ignore it */}
<input name="website" type="text" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
<input name="name" type="text" required placeholder="Your name" />
<input name="email" type="email" required placeholder="Email address" />
<input name="phone" type="tel" required placeholder="Phone number" />
<textarea name="message" required placeholder="How can Teressa help?" />
{state.status === 'error' && <p role="alert">{state.message}</p>}
<button type="submit" disabled={pending}>
{pending ? 'Sending...' : 'Send Message'}
</button>
</form>
)
}
Anti-Patterns to Avoid
- Using
useFormStatefromreact-dom: Removed in React 19. UseuseActionStatefromreactinstead. - Storing contact form submissions in DB in this phase: CONTEXT.md locked to email-only delivery. DB storage is out of scope.
- Importing carousel library (Swiper, Embla): Adds unnecessary bundle for 4-5 static testimonials. Hand-roll with useState/useEffect.
- Putting a link to
/agent/loginon the public site: Explicitly locked out of scope — agent navigates there directly via URL. - Hardcoding SMTP credentials: Always read from
CONTACT_EMAIL_USER/CONTACT_EMAIL_PASSenv vars. - Using
router.pushfor smooth scroll: Use CSSscroll-behavior: smooth+<a href="#id">anchors instead.
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| SMTP email delivery | Custom fetch to mail API | nodemailer |
Handles STARTTLS, auth, connection pooling, error codes |
| Form validation | Manual regex/string checks | zod (already installed) |
Already in project; handles type coercion, error messages |
| Image optimization | Raw <img> tags |
next/image |
Automatic WebP conversion, lazy load, responsive srcset |
| Smooth scroll | window.scrollTo() JS |
CSS scroll-behavior: smooth |
Native browser feature; no JS required |
Key insight: The SMTP connection lifecycle (STARTTLS negotiation, auth failures, timeouts, connection pooling) is non-trivial. Nodemailer handles all of it — never reimplement it.
Common Pitfalls
Pitfall 1: useFormState vs useActionState (React 19 breaking change)
What goes wrong: Developer uses useFormState from react-dom/server — module not found or deprecated warning.
Why it happens: Most tutorials pre-date React 19. The hook moved.
How to avoid: Import useActionState from react (not react-dom). React 19.2.4 is in the project.
Warning signs: TypeScript error "useFormState is not exported from react-dom".
Pitfall 2: Sticky Nav Overlapping Section Anchors
What goes wrong: Clicking "Contact" scrolls so the section heading is hidden behind the sticky nav.
Why it happens: Browser positions the anchor at top: 0, but sticky nav covers it.
How to avoid: Add scroll-margin-top to each section equal to nav height.
section[id] { scroll-margin-top: 72px; } /* match nav height */
Pitfall 3: Carousel setInterval Memory Leak
What goes wrong: Multiple intervals accumulate on re-renders; carousel skips or fires multiple times per second.
Why it happens: useEffect cleanup not returning clearInterval.
How to avoid: Always clear the previous interval in the useEffect cleanup function. Use a ref to track the interval ID (shown in Pattern 3 above).
Pitfall 4: Honeypot Field Autofilled by Browser Password Manager
What goes wrong: Browser autofills the honeypot field, causing legitimate submissions to be silently discarded.
Why it happens: The hidden field has a name that triggers autofill (e.g., name="email2").
How to avoid: Name the honeypot something bots seek but browsers won't autofill (e.g., name="website" or name="url"). Add autoComplete="off" and tabIndex={-1}. Use style={{ display: 'none' }} rather than type="hidden" (bots fill visible-in-DOM fields more reliably).
Pitfall 5: Nodemailer from Address Rejected by SMTP Server
What goes wrong: Email silently fails or bounces because from address doesn't match authenticated user.
Why it happens: Gmail/SMTP providers require from to match the authenticated account.
How to avoid: Set from to process.env.CONTACT_EMAIL_USER (Teressa's SMTP account) and use replyTo for the visitor's email. The pattern in Pattern 2 above handles this correctly.
Pitfall 6: Next.js Image Component with Local File
What goes wrong: <Image src="/red.jpg" /> throws "Image is missing required 'width' or 'height'" or renders distorted.
Why it happens: Next.js Image requires explicit dimensions OR fill + positioned parent.
How to avoid: For the hero split-panel, use fill with objectFit="cover" on a positioned container.
// Hero photo container
<div style={{ position: 'relative', height: '100%', minHeight: '500px' }}>
<Image src="/red.jpg" alt="Teressa Copeland" fill style={{ objectFit: 'cover', objectPosition: 'center top' }} priority />
</div>
Code Examples
Environment Variables Required
Add to .env.local (never commit):
# Contact form SMTP
CONTACT_EMAIL_USER=teressa@teressacopelandhomes.com
CONTACT_EMAIL_PASS=your_app_password
CONTACT_SMTP_HOST=smtp.gmail.com
CONTACT_SMTP_PORT=587
Note: Add CONTACT_SMTP_HOST and CONTACT_SMTP_PORT in addition to the CONTEXT.md variables — the host/port pair is required by Nodemailer but not mentioned in the user decisions. Use Gmail app password or equivalent.
Sticky Nav with Mobile Toggle
// app/_components/SiteNav.tsx
'use client'
import { useState } from 'react'
import { Menu, X } from 'lucide-react'
const NAV_LINKS = [
{ label: 'Home', href: '#hero' },
{ label: 'About', href: '#about' },
{ label: 'Listings', href: '#listings' },
{ label: 'Contact', href: '#contact' },
]
export function SiteNav() {
const [open, setOpen] = useState(false)
return (
<nav style={{
position: 'sticky', top: 0, zIndex: 50,
backgroundColor: '#1B2B4B', color: '#FAF9F7',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem 2rem' }}>
{/* Wordmark */}
<span style={{ fontWeight: 700, letterSpacing: '0.05em' }}>Teressa Copeland</span>
{/* Desktop nav links */}
<ul className="hidden md:flex gap-8">
{NAV_LINKS.map(l => <li key={l.href}><a href={l.href}>{l.label}</a></li>)}
</ul>
{/* Mobile hamburger */}
<button className="md:hidden" onClick={() => setOpen(o => !o)}>
{open ? <X /> : <Menu />}
</button>
</div>
{/* Mobile drawer */}
{open && (
<ul style={{ backgroundColor: '#1B2B4B', padding: '1rem 2rem' }}>
{NAV_LINKS.map(l => (
<li key={l.href}><a href={l.href} onClick={() => setOpen(false)}>{l.label}</a></li>
))}
</ul>
)}
</nav>
)
}
Footer with License Number
// app/_components/SiteFooter.tsx
export function SiteFooter() {
return (
<footer style={{ backgroundColor: '#1B2B4B', color: '#FAF9F7', padding: '2rem' }}>
<p>Teressa Copeland</p>
{/* TODO: Verify license number before launch — last two chars may be "00" (zeros) or "OO" (letters O) */}
<p>Utah Real Estate License: 14196185-SA00</p>
<p>© {new Date().getFullYear()} Teressa Copeland Homes. All rights reserved.</p>
</footer>
)
}
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
useFormState from react-dom |
useActionState from react |
React 19 (2024) | Old import throws; use new hook |
| API Routes for form submit | Server Actions with action={serverAction} |
Next.js 14+ | No fetch, no /api/ endpoint needed |
pages/ router |
App Router (app/) |
Next.js 13+ | Project already uses App Router from Phase 1 |
<img> tags |
next/image |
Next.js 10+ | Auto-optimization, lazy loading, responsive |
Deprecated/outdated:
useFormStatefromreact-dom/server: Removed in React 19. UseuseActionStatefromreact.- Pages Router API routes for forms: Superseded by Server Actions in App Router.
Open Questions
-
SMTP provider / Gmail App Password
- What we know: CONTEXT.md locked
CONTACT_EMAIL_USERandCONTACT_EMAIL_PASSas env vars - What's unclear: Whether Teressa uses Gmail (app password), Namecheap hosting email, or another provider — affects
host/portconfig - Recommendation: Add
CONTACT_SMTP_HOSTandCONTACT_SMTP_PORTenv vars so the planner does not hardcode a provider. Document Gmail setup as the example (host: smtp.gmail.com, port: 587, secure: false, requires App Password with 2FA enabled).
- What we know: CONTEXT.md locked
-
Lucide React installation status
- What we know: CONTEXT.md says "use Lucide React (already likely available)" but it does not appear in package.json dependencies
- What's unclear: Whether it was installed but not listed, or needs to be added
- Recommendation: Plan task should
npm install lucide-reactas a safe no-op if already present.
-
License number last two characters
- What we know:
14196185-SA00— user flagged uncertainty whether final chars are00(zeros) orOO(letters) - What's unclear: The correct legal license number
- Recommendation: Add a
// TODO: VERIFY LICENSE NUMBERcomment in the footer code. Do not block implementation on this — it's a content swap before launch.
- What we know:
Sources
Primary (HIGH confidence)
- Next.js 16 App Router — Server Actions,
useActionState,next/imagepatterns: https://nextjs.org/docs/app/building-your-application/data-fetching/server-actions-and-mutations - React 19
useActionStateAPI: https://react.dev/reference/react/useActionState - Nodemailer official docs: https://nodemailer.com/about/
- Project package.json (confirmed versions): Next.js 16.2.0, React 19.2.4, Zod ^4.3.6, Tailwind ^4
Secondary (MEDIUM confidence)
- CSS
scroll-behavior: smooth+scroll-margin-topMDN: https://developer.mozilla.org/en-US/docs/Web/CSS/scroll-margin-top - Honeypot spam protection pattern — multiple community sources agree on
display:none+tabIndex={-1}+autoComplete="off"approach
Tertiary (LOW confidence — validate before implementation)
- Gmail SMTP settings (host, port, app password requirement) — widely documented but Teressa's actual provider unknown until confirmed
Metadata
Confidence breakdown:
- Standard stack: HIGH — package.json confirms exact versions; no guessing
- Architecture: HIGH — Next.js App Router patterns are stable and well-documented
- Carousel pattern: HIGH — pure React useState/useEffect; no library dependency
- Server Action + Nodemailer: HIGH — Next.js official docs confirm pattern; Nodemailer is stable
- SMTP provider specifics: LOW — depends on Teressa's email provider, unknown at research time
Research date: 2026-03-19 Valid until: 2026-04-19 (30 days — Next.js 16 and Nodemailer are stable; Tailwind 4 and React 19 patterns are settling but unlikely to break)