Production Polish: SEO, Forms & CI/CD

Add dynamic SEO, a Server Actions contact form, and CI/CD to Next.js.

Introduction

30 Second Summary

A website that nobody can find on Google is invisible. A website with no way to collect leads is useless to a client. Turning a beautiful CMS frontend into a production-ready business tool means solving both of these problems.

In this project, you will add dynamic SEO metadata pulled from your Sanity content, build a contact form that validates input and sends real emails, and finalize an automated deployment pipeline on Vercel.

What You'll Build

A production-hardened agency CMS where every page is SEO-optimized with metadata from your content lake, a contact form submits server-side with validation and delivers real emails, and the entire system deploys automatically on push.

By the end of this project, you'll have:

  • A generateMetadata implementation that queries Sanity for dynamic titles, descriptions, and Open Graph images on every page.
  • A production contact form powered by Server Actions with Zod validation and email delivery through the Resend SDK.
  • A finalized CI/CD pipeline on Vercel with environment variables properly scoped between preview and production deployments.
  • Secret Mission: Generate a programmatic sitemap.xml that dynamically queries all published content URLs from Sanity.

Are there any prerequisites?

This project builds directly on Project 1 (The Core CMS Engine). You need a working Next.js 16 + React 19 frontend connected to an embedded Sanity Studio, plus an active Resend account with a verified custom domain, a GitHub repository, and a Vercel account.

Install Dependencies and Configure the Metadata Client

Before building SEO metadata and a contact form, you need the right packages in your project.

You'll install Resend for transactional email delivery and Zod for form validation. You'll also configure a metadata-safe Sanity client that keeps search engine strings clean.

In this step, get ready to:
  • Install the Resend SDK and Zod validation library.
  • Create a metadata-safe Sanity client with stega encoding disabled.
  • Add your Resend API key to the environment configuration.
Install Resend SDK and Zod

Your project needs two new packages. Resend handles sending transactional emails from your server, and Zod validates form data before it reaches your action logic.

  • Install both packages by running this command in your terminal:
npm install resend@6.12.4 zod@4.4.3

You should see output confirming both packages were added to your project.

  • Confirm the versions installed correctly by running:
cat node_modules/resend/package.json | grep version

You should see 6.12.4 in the output.

Install failed or timed out?

Make sure you're running the command from your project's root folder (the one containing package.json).

If you see permission errors on macOS, try prefixing the command with sudo. If the install hangs, check your internet connection and try again.

help me troubleshoot why npm install is failing

Create the metadata-safe Sanity client

Sanity's Visual Editing feature uses stega encoding to embed invisible Unicode characters into content strings. These characters enable click-to-edit overlays in the browser, but they would pollute your SEO metadata if left in place.

You'll create a dedicated client variant that disables stega, keeping metadata strings clean for search engines.

  • Create a new file at src/sanity/lib/metadata-client.ts and add the following code:
import { client } from './client'

// Disable stega encoding for metadata to prevent invisible
// characters from polluting SEO strings
export const metadataClient = client.withConfig({
  stega: false,
})

What does this code do?

This file imports the existing Sanity client from your Project 1 setup and creates a new variant with one config override.

The stega: false setting strips invisible characters that Visual Editing injects. When you later use metadataClient inside generateMetadata, your page titles and descriptions will be clean text that search engines can read correctly.

  • Save src/sanity/lib/metadata-client.ts.

Seeing a module not found error?

Make sure the import path './client' matches the filename of your existing Sanity client file. In Project 1, this file should exist at src/sanity/lib/client.ts.

help me fix my metadata client import error

✔️ Awesome, I've got everything!

Great. Make sure you've saved the file before moving on.

ⓧ I'd like to double check the full code

import { client } from './client'

// Disable stega encoding for metadata to prevent invisible
// characters from polluting SEO strings
export const metadataClient = client.withConfig({
  stega: false,
})
Configure environment variables

The Resend SDK needs an API key to authenticate email requests. You'll store this key in your .env.local file so it stays server-side and out of version control.

  • In the Resend dashboard, click API Keys in the sidebar.
  • Copy your existing API key, or click Create API Key to generate a new one.
  • Open your .env.local file in your editor and add this line:
RESEND_API_KEY=your-resend-api-key-here
  • Replace your-resend-api-key-here with the actual API key you copied from the Resend dashboard.
  • Save .env.local.

Why no NEXT_PUBLIC_ prefix?

Environment variables without the NEXT_PUBLIC_ prefix are only available on the server. Since RESEND_API_KEY is a secret, keeping it server-side prevents it from leaking into client-side JavaScript bundles.

Not sure where .env.local is?

This file lives in the root of your project folder, alongside package.json. If you completed Project 1, it should already exist with your Sanity environment variables.

help me find or create my .env.local file

Now confirm everything works together.

  • Start your dev server by running:
npm run dev

Your app should boot without errors. If the terminal shows Ready with a local URL, the metadata client is exporting cleanly and your environment variable is loaded.

Dev server won't start?

If you see a TypeScript error referencing metadata-client.ts, double-check that src/sanity/lib/client.ts exists and exports a client variable.

If you see warnings about missing environment variables, confirm .env.local is saved in the project root (not inside src/).

help me debug my dev server startup error

Your production dependencies are installed and the metadata client is ready. Next up, you'll use this client to generate dynamic SEO metadata from your Sanity content.

Implement Dynamic SEO Metadata with GROQ

Your metadata client is ready to fetch clean, stega-free strings from Sanity. Now you need to tell Next.js what to do with those strings.

Search engines read your page's <title>, <meta> descriptions, and Open Graph images to decide how to rank and preview your content. Right now, every page on your site serves the same generic metadata. In this step, you will wire each post page's metadata directly to your Sanity content lake so search engines always get accurate, per-page information.

In this step, get ready to:
  • Define a GROQ query that fetches SEO fields (title, description, OG image URL) for a given post.
  • Export a generateMetadata function in your dynamic route page.
  • Set a root-level metadataBase so all Open Graph URLs resolve correctly.
Define the metadata GROQ query

Before you can generate metadata, your app needs a query that fetches only the fields search engines care about. A dedicated metadata query keeps the response lean and avoids pulling heavy fields like body content.

  • In src/sanity/lib/queries.ts, find your existing POST_QUERY definition.
  • Add the following two new queries below it:
export const POST_METADATA_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    title,
    description,
    "ogImageUrl": mainImage.asset->url
  }`
)

export const ALL_POSTS_SLUGS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)]{
    "slug": slug.current,
    _updatedAt
  }`
)

What do these queries do?

  • POST_METADATA_QUERY fetches a single post by slug and projects only the three fields needed for SEO: title, description, and the image asset URL via the -> dereference operator.
  • ALL_POSTS_SLUGS_QUERY retrieves every published post's slug and last-updated timestamp. You will use this later for sitemap generation.
  • Save src/sanity/lib/queries.ts.

Why defineQuery instead of the groq tag?

defineQuery is the recommended pattern for next-sanity v12+. It enables TypeGen inference and syntax highlighting with the Sanity VS Code extension, giving you autocompletion on your query results.

Seeing a TypeScript error on the query?

Make sure you are importing defineQuery from next-sanity at the top of the file. Your existing queries already use it, so confirm the import line is still present.

help me fix a TypeScript error in my GROQ queries file

Implement generateMetadata in the dynamic route page

Next.js uses a special exported function called generateMetadata to produce per-page <head> tags at request time. When you export this function from a route file, Next.js calls it before rendering and injects the returned metadata into the page's HTML head.

  • Open src/app/posts/[slug]/page.tsx and replace its entire contents with the following code:
import type { Metadata } from 'next'
import { metadataClient } from '@/sanity/lib/metadata-client'
import { POST_METADATA_QUERY, POST_QUERY } from '@/sanity/lib/queries'
import { sanityFetch } from '@/sanity/lib/live'

type Props = {
  params: Promise<{ slug: string }>
}

// Generate dynamic SEO metadata for each post page
export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  // Fetch only SEO fields using the stega-disabled client
  const post = await metadataClient.fetch(POST_METADATA_QUERY, { slug })

  if (!post) {
    return {}
  }

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      images: post.ogImageUrl ? [{ url: post.ogImageUrl }] : [],
    },
  }
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const { data: post } = await sanityFetch({ query: POST_QUERY, params: { slug } })

  if (!post) {
    return <div>Post not found</div>
  }

  return (
    <main>
      <h1>{post.title}</h1>
      {/* Render post body with PortableText */}
    </main>
  )
}

What does this code do?

  • params is a Promise in Next.js 16, so you must await it before accessing the slug.
  • generateMetadata uses metadataClient (stega disabled) instead of sanityFetch so that invisible Visual Editing characters never leak into your SEO strings.
  • If no post matches the slug, the function returns an empty object so Next.js falls back to the parent layout's default metadata.
  • The openGraph.images array conditionally includes the OG image only when a mainImage exists on the Sanity document.
  • Save src/app/posts/[slug]/page.tsx.

Getting a type error on params?

In Next.js 16, route params is typed as a Promise. If you see a type mismatch, confirm your Props type uses Promise<{ slug: string }> and that you await params inside both functions.

help me fix a params type error in my Next.js 16 page

Add root layout metadataBase and default metadata

Open Graph images use relative URLs by default. Without a metadataBase, Next.js cannot resolve those relative paths into full URLs that social platforms can fetch.

  • Open src/app/layout.tsx and replace its contents with the following:
import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  // Base URL for resolving relative OG image paths
  metadataBase: new URL('https://your-production-domain.com'),
  title: {
    // %s is replaced by child route titles
    template: '%s | Agency Name',
    default: 'Agency Name',
  },
  description: 'Your agency description for SEO fallback.',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

What does this code do?

  • metadataBase sets the base URL that all relative metadata URLs (like OG images) are resolved against. Replace this with your actual production domain once you deploy.
  • title.template wraps every child page's title with your brand suffix. A post titled "My Article" becomes "My Article | Agency Name" in the browser tab.
  • title.default and description serve as fallbacks when a child page does not define its own metadata.
  • Save src/app/layout.tsx.
  • Return to your browser with the dev server running and navigate to any post page.
  • Right-click the page and select View Page Source to inspect the raw HTML.
  • Confirm you see your post's title inside a <title> tag, a <meta name="description"> tag with content from Sanity, and a <meta property="og:image"> tag pointing to your image URL.

Why View Page Source instead of DevTools Elements?

Next.js 16 streams metadata tags to improve loading performance. In the live DOM (DevTools Elements panel), streamed meta tags may temporarily appear inside <body> before hydration moves them to <head>. View Page Source shows the raw server response, where you can verify the tags are present and correct.

Metadata tags missing or showing stega characters?

  • If tags are empty, confirm your Sanity post document has a description field and a mainImage with an uploaded asset.
  • If you see invisible characters (garbled text in the title), double-check that generateMetadata uses metadataClient (stega disabled) and not sanityFetch.
  • If OG image URL is missing, verify the GROQ projection uses mainImage.asset->url with the dereference arrow.

help me debug missing metadata on my Next.js post page

✔️ Awesome, I've got everything!

Great. Double check you have saved all three files before continuing.

ⓧ I'd like to double check the full code

Here is the complete src/sanity/lib/queries.ts file:

import { defineQuery } from 'next-sanity'

export const POSTS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)][0...12]{
    _id, title, slug
  }`
)

export const POST_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    title, body, mainImage
  }`
)

export const POST_METADATA_QUERY = defineQuery(
  `*[_type == "post" && slug.current == $slug][0]{
    title,
    description,
    "ogImageUrl": mainImage.asset->url
  }`
)

export const ALL_POSTS_SLUGS_QUERY = defineQuery(
  `*[_type == "post" && defined(slug.current)]{
    "slug": slug.current,
    _updatedAt
  }`
)

Here is the complete src/app/posts/[slug]/page.tsx file:

import type { Metadata } from 'next'
import { metadataClient } from '@/sanity/lib/metadata-client'
import { POST_METADATA_QUERY, POST_QUERY } from '@/sanity/lib/queries'
import { sanityFetch } from '@/sanity/lib/live'

type Props = {
  params: Promise<{ slug: string }>
}

export async function generateMetadata({ params }: Props): Promise<Metadata> {
  const { slug } = await params

  const post = await metadataClient.fetch(POST_METADATA_QUERY, { slug })

  if (!post) {
    return {}
  }

  return {
    title: post.title,
    description: post.description,
    openGraph: {
      title: post.title,
      description: post.description,
      images: post.ogImageUrl ? [{ url: post.ogImageUrl }] : [],
    },
  }
}

export default async function PostPage({ params }: Props) {
  const { slug } = await params
  const { data: post } = await sanityFetch({ query: POST_QUERY, params: { slug } })

  if (!post) {
    return <div>Post not found</div>
  }

  return (
    <main>
      <h1>{post.title}</h1>
      {/* Render post body with PortableText */}
    </main>
  )
}

Here is the complete src/app/layout.tsx file:

import type { Metadata } from 'next'
import './globals.css'

export const metadata: Metadata = {
  metadataBase: new URL('https://your-production-domain.com'),
  title: {
    template: '%s | Agency Name',
    default: 'Agency Name',
  },
  description: 'Your agency description for SEO fallback.',
}

export default function RootLayout({
  children,
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

Every post page now serves unique, CMS-driven metadata to search engines and social platforms. Next up, you will build a contact form that submits server-side with validation and sends real emails through Resend.

Build the Contact Form Server Action

Your site now has dynamic SEO metadata pulled straight from Sanity. Every post page renders its own title, description, and Open Graph image for search engines.

Now you need a way for visitors to contact you. Instead of building a separate API route and wiring up client-side fetch calls, you'll use a Server Action that runs server-side validation with Zod and delivers email through the Resend SDK.

In this step, get ready to:
  • Define a Zod validation schema for contact form fields.
  • Implement a Server Action that validates input and sends email via Resend.
  • Verify the action delivers emails end-to-end.
Define the Zod schema and action types

Before writing the action logic, you need a schema that defines what valid contact form data looks like. Zod lets you declare field constraints (required, email format, minimum length) and returns structured error messages per field when validation fails.

  • Create the folder src/app/actions/ inside your project.
  • Create a new file src/app/actions/contact.ts and add the following imports, schema, and type definitions:
'use server'

import * as z from 'zod'
import { Resend } from 'resend'

// Define validation rules for each contact form field
const contactSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Please enter a valid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
})

// Shape of the state object returned to the client component
export type ContactFormState = {
  success: boolean
  message: string
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
}

// Starting state before any submission
const initialState: ContactFormState = {
  success: false,
  message: '',
}

What does this code do?

The 'use server' directive at the top of the file tells Next.js that every exported function in this file is a Server Action. It runs exclusively on the server and is never bundled into client-side JavaScript.

The contactSchema uses Zod to enforce that name is non-empty, email matches email format, and message is at least 10 characters. When validation fails, Zod produces per-field error arrays that the UI can display inline.

The ContactFormState type defines the shape that both the Server Action and the client component share. This gives you end-to-end type safety between server and client.

Integrate the Resend SDK for email delivery

With the schema in place, you can write the action function itself. It validates incoming form data, then sends an email through Resend if validation passes.

  • In src/app/actions/contact.ts, add the sendContactEmail function below the initialState constant:
export async function sendContactEmail(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  // Extract raw field values from the submitted form
  const rawData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    message: formData.get('message') as string,
  }

  // Validate against the Zod schema
  const result = contactSchema.safeParse(rawData)

  if (!result.success) {
    return {
      success: false,
      message: 'Please fix the errors below.',
      errors: result.error.flatten().fieldErrors,
    }
  }

  // Instantiate Resend with the server-side API key
  const resend = new Resend(process.env.RESEND_API_KEY)

  // Send the email using validated data
  const { data, error } = await resend.emails.send({
    from: 'Contact Form <contact@yourdomain.com>',
    to: ['inbox@yourdomain.com'],
    subject: `New message from ${result.data.name}`,
    replyTo: result.data.email,
    html: `
      <h2>New Contact Form Submission</h2>
      <p><strong>Name:</strong> ${result.data.name}</p>
      <p><strong>Email:</strong> ${result.data.email}</p>
      <p><strong>Message:</strong></p>
      <p>${result.data.message}</p>
    `,
  })

  if (error) {
    return {
      success: false,
      message: 'Failed to send message. Please try again.',
    }
  }

  return {
    success: true,
    message: 'Message sent successfully. We will get back to you soon.',
  }
}

What does this code do?

The function signature accepts prevState as the first parameter because this action will be used with React's useActionState hook in the next step. The hook passes the previous state automatically on each submission.

The safeParse call validates without throwing. On failure, result.error.flatten().fieldErrors produces a flat object mapping each field name to its error messages. This structure maps directly to inline error display in the form UI.

On success, the action instantiates the Resend client with your server-side RESEND_API_KEY and calls resend.emails.send(). The replyTo field ensures that hitting Reply in your inbox addresses the person who filled out the form.

Why Server Actions instead of an API route?

A Server Action removes the need for a separate /api/contact route file, client-side fetch boilerplate, and manual error-state management. The action is co-located with the form logic, type-checked end-to-end, and works with progressive enhancement (forms submit even before JavaScript loads).

  • Save src/app/actions/contact.ts.
  • Confirm your dev server still runs without errors in the terminal.

Seeing a TypeScript error about non-async exports?

Files with 'use server' at the top can only export async functions. If you accidentally export a plain object or non-async function, Next.js throws an Invalid 'use server' Value error. The ContactFormState type export is fine because TypeScript types are erased at compile time.

help me fix a 'use server' export error in my Server Action file

✔️ Awesome, I've got everything!

Great. Double check you have saved src/app/actions/contact.ts and your terminal shows no errors.

ⓧ I'd like to double check the full code

'use server'

import * as z from 'zod'
import { Resend } from 'resend'

const contactSchema = z.object({
  name: z.string().min(1, 'Name is required'),
  email: z.string().email('Please enter a valid email address'),
  message: z.string().min(10, 'Message must be at least 10 characters'),
})

export type ContactFormState = {
  success: boolean
  message: string
  errors?: {
    name?: string[]
    email?: string[]
    message?: string[]
  }
}

const initialState: ContactFormState = {
  success: false,
  message: '',
}

export async function sendContactEmail(
  prevState: ContactFormState,
  formData: FormData
): Promise<ContactFormState> {
  const rawData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    message: formData.get('message') as string,
  }

  const result = contactSchema.safeParse(rawData)

  if (!result.success) {
    return {
      success: false,
      message: 'Please fix the errors below.',
      errors: result.error.flatten().fieldErrors,
    }
  }

  const resend = new Resend(process.env.RESEND_API_KEY)

  const { data, error } = await resend.emails.send({
    from: 'Contact Form <contact@yourdomain.com>',
    to: ['inbox@yourdomain.com'],
    subject: `New message from ${result.data.name}`,
    replyTo: result.data.email,
    html: `
      <h2>New Contact Form Submission</h2>
      <p><strong>Name:</strong> ${result.data.name}</p>
      <p><strong>Email:</strong> ${result.data.email}</p>
      <p><strong>Message:</strong></p>
      <p>${result.data.message}</p>
    `,
  })

  if (error) {
    return {
      success: false,
      message: 'Failed to send message. Please try again.',
    }
  }

  return {
    success: true,
    message: 'Message sent successfully. We will get back to you soon.',
  }
}
Validate end-to-end in isolation

Before building the form UI, confirm the Server Action works correctly on its own. You can test it by creating a temporary page that calls the action directly.

  • Create a temporary test file at src/app/test-action/page.tsx with a minimal form that posts to your action:
'use client'

import { useActionState } from 'react'
import { sendContactEmail, type ContactFormState } from '@/app/actions/contact'

const initialState: ContactFormState = {
  success: false,
  message: '',
}

export default function TestActionPage() {
  const [state, formAction, pending] = useActionState(
    sendContactEmail,
    initialState
  )

  return (
    <form action={formAction}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <textarea name="message" placeholder="Message (min 10 chars)" />
      <button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send'}
      </button>
      <pre>{JSON.stringify(state, null, 2)}</pre>
    </form>
  )
}
  • Save the file and navigate to http://localhost:3000/test-action in your browser.
  • Submit the form with empty fields. Confirm the state output shows field-specific error messages (e.g. Name is required).
  • Fill in valid data (a name, valid email, and a message of at least 10 characters). Submit and confirm success: true appears in the output.
  • Check your email inbox for the test message delivered by Resend.

What should I see?

With invalid data, the JSON output below the form shows success: false with an errors object containing arrays of error messages per field. With valid data, you see success: true and the message Message sent successfully. We will get back to you soon. in the output.

You can also verify delivery in your Resend dashboard under the Logs section.

Not receiving the email?

Make sure your RESEND_API_KEY is set correctly in .env.local. The key must be a valid Resend API key from your dashboard under Settings > API Keys.

The from address must use a domain you have verified in Resend. If you have not verified a custom domain yet, Resend only allows sending from onboarding@resend.dev during testing.

help me debug why my Resend email is not being delivered from my Server Action

  • Once testing is complete, delete the src/app/test-action/ folder. It was only needed for isolated verification.

Your Server Action validates and delivers email end-to-end. Next up, you'll build the client-side form component that connects to this action with real-time error display and a loading state.

Build the Contact Form Client Component

Your Server Action validates input and sends email through Resend. Now you need a client-side form that submits to it and reflects the action's state back to the user.

React 19's useActionState hook connects your form directly to the Server Action. It tracks pending state, captures returned errors, and triggers re-renders when the action completes.

In this step, get ready to:
  • Create a ContactForm client component wired to the Server Action with useActionState.
  • Display field-level validation errors and pending state for accessibility.
  • Mount the form on a dedicated contact page with static SEO metadata.
Create the form component with useActionState

The useActionState hook from React takes a Server Action and an initial state object, then returns three values: the current state, a form action to bind, and a pending boolean.

  • Create a new file at src/components/ContactForm.tsx and paste the following code:
'use client'

import { useActionState } from 'react'
import { sendContactEmail, type ContactFormState } from '@/app/actions/contact'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'

const initialState: ContactFormState = {
  success: false,
  message: '',
}

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    sendContactEmail,
    initialState
  )

  return (
    <form action={formAction} className="space-y-6 max-w-lg">
      <div>
        <Label htmlFor="name">Name</Label>
        <Input
          id="name"
          name="name"
          placeholder="Your name"
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <Label htmlFor="email">Email</Label>
        <Input
          id="email"
          name="email"
          type="email"
          placeholder="you@example.com"
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <Label htmlFor="message">Message</Label>
        <Textarea
          id="message"
          name="message"
          placeholder="Your message (min 10 characters)"
          rows={5}
          aria-describedby="message-error"
        />
        {state.errors?.message && (
          <p id="message-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      <Button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send Message'}
      </Button>

      {state.message && !state.errors && (
        <p
          aria-live="polite"
          className={state.success ? 'text-green-600' : 'text-red-500'}
        >
          {state.message}
        </p>
      )}
    </form>
  )
}

What does this code do?

useActionState(sendContactEmail, initialState) returns three values:

  • state holds the latest return value from your Server Action (errors, success status, message).
  • formAction is a function you bind to <form action={formAction}> so the form submits through the Server Action.
  • pending is a boolean that becomes true while the action runs and false when it finishes.

The 'use client' directive marks this as a Client Component because hooks like useActionState require client-side interactivity.

  • Save src/components/ContactForm.tsx.

You won't see a change in the browser yet. The contact page you create in the next substep will mount this form.

Seeing import errors in your editor?

  • Make sure the 'use client' directive is the very first line of the file. Nothing can come before it.
  • Confirm your shadcn/ui components exist at src/components/ui/. If you haven't added Button, Input, Textarea, or Label yet, install them through your shadcn/ui CLI.
  • Check that src/app/actions/contact.ts exports both sendContactEmail and ContactFormState.

Help me fix import errors in my ContactForm component

Implement error display and pending state

Your ContactForm already includes three UX patterns that make it production-ready. Let's trace how each one works.

How does accessible error display work?

Each input has an aria-describedby attribute pointing to an error paragraph's id. Screen readers announce the error text immediately after the field label when the error element appears.

The aria-live="polite" attribute tells assistive technology to announce new content when the user is idle. This means validation errors are read aloud without interrupting the user mid-keystroke.

How does pending state protect against double-submits?

The pending boolean from useActionState is true from the moment the form submits until the Server Action returns. Binding disabled={pending} to the submit button prevents duplicate submissions.

The ternary {pending ? 'Sending...' : 'Send Message'} swaps the button text to give the user immediate visual feedback that something is happening.

How does the success message render?

The condition state.message && !state.errors ensures the status paragraph only renders after a completed action (not during validation failures, where field-level errors already communicate the problem).

The className switches between text-green-600 and text-red-500 based on state.success so the user gets a color signal alongside the message text.

Mount the form on a contact page

Your ContactForm is a Client Component, but the page that renders it can remain a Server Component. This keeps the page itself lightweight and lets you export static metadata for SEO.

  • Create a new file at src/app/contact/page.tsx and paste the following code:
import type { Metadata } from 'next'
import { ContactForm } from '@/components/ContactForm'

export const metadata: Metadata = {
  title: 'Contact',
  description: 'Get in touch with our agency. We respond within 24 hours.',
}

export default function ContactPage() {
  return (
    <main className="container mx-auto max-w-2xl py-16 px-4">
      <h1 className="text-3xl font-bold mb-8">Get in Touch</h1>
      <p className="text-muted-foreground mb-8">
        Fill out the form below and we will get back to you within 24 hours.
      </p>
      <ContactForm />
    </main>
  )
}

What does this code do?

  • The exported metadata object provides a static title and description. Because the root layout sets title.template: '%s | Agency Name', this page renders as "Contact | Agency Name" in the browser tab.
  • ContactPage is a Server Component (no 'use client' directive). It renders static markup around the interactive ContactForm Client Component.
  • Save src/app/contact/page.tsx.
  • In your browser, navigate to http://localhost:3000/contact. You should see the heading "Get in Touch" followed by name, email, and message fields with a "Send Message" button.

Page not rendering at /contact?

  • Confirm the file path is exactly src/app/contact/page.tsx. The folder name contact maps to the /contact URL route.
  • Check your terminal for TypeScript errors. A missing ContactForm export or incorrect import path will prevent the page from compiling.
  • Make sure your dev server is still running. If it crashed, restart it with npm run dev.

Help me debug why my /contact page isn't loading

✔️ Awesome, I've got everything!

Great. Make sure both files are saved before you test the form.

ⓧ I'd like to double check the full code

Here is the complete src/components/ContactForm.tsx:

'use client'

import { useActionState } from 'react'
import { sendContactEmail, type ContactFormState } from '@/app/actions/contact'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'

const initialState: ContactFormState = {
  success: false,
  message: '',
}

export function ContactForm() {
  const [state, formAction, pending] = useActionState(
    sendContactEmail,
    initialState
  )

  return (
    <form action={formAction} className="space-y-6 max-w-lg">
      <div>
        <Label htmlFor="name">Name</Label>
        <Input
          id="name"
          name="name"
          placeholder="Your name"
          aria-describedby="name-error"
        />
        {state.errors?.name && (
          <p id="name-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.name[0]}
          </p>
        )}
      </div>

      <div>
        <Label htmlFor="email">Email</Label>
        <Input
          id="email"
          name="email"
          type="email"
          placeholder="you@example.com"
          aria-describedby="email-error"
        />
        {state.errors?.email && (
          <p id="email-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.email[0]}
          </p>
        )}
      </div>

      <div>
        <Label htmlFor="message">Message</Label>
        <Textarea
          id="message"
          name="message"
          placeholder="Your message (min 10 characters)"
          rows={5}
          aria-describedby="message-error"
        />
        {state.errors?.message && (
          <p id="message-error" aria-live="polite" className="text-sm text-red-500 mt-1">
            {state.errors.message[0]}
          </p>
        )}
      </div>

      <Button type="submit" disabled={pending}>
        {pending ? 'Sending...' : 'Send Message'}
      </Button>

      {state.message && !state.errors && (
        <p
          aria-live="polite"
          className={state.success ? 'text-green-600' : 'text-red-500'}
        >
          {state.message}
        </p>
      )}
    </form>
  )
}

And here is src/app/contact/page.tsx:

import type { Metadata } from 'next'
import { ContactForm } from '@/components/ContactForm'

export const metadata: Metadata = {
  title: 'Contact',
  description: 'Get in touch with our agency. We respond within 24 hours.',
}

export default function ContactPage() {
  return (
    <main className="container mx-auto max-w-2xl py-16 px-4">
      <h1 className="text-3xl font-bold mb-8">Get in Touch</h1>
      <p className="text-muted-foreground mb-8">
        Fill out the form below and we will get back to you within 24 hours.
      </p>
      <ContactForm />
    </main>
  )
}
  • Submit the form with all fields empty. You should see red error text below the name and email fields.
  • Fill in a valid name, email, and a message longer than 10 characters, then click Send Message. The button text should change to "Sending..." briefly, then a green success message appears.
  • Check your email inbox for the submitted message.

What should I see?

On invalid submission, each field with errors shows a red message directly below the input (e.g. "Name is required"). On valid submission, the button briefly shows "Sending..." then the form displays "Message sent successfully. We will get back to you soon." in green.

Your inbox should receive an email from your verified domain with the submitted name, email, and message formatted in HTML.

Form submits but no email arrives?

  • Check your RESEND_API_KEY in .env.local. The key must be valid and associated with your verified domain.
  • The from address in your Server Action must use your verified domain. Sending from an unverified domain causes Resend to reject the request silently.
  • Check the Resend dashboard's Logs section for failed or bounced sends.

Help me debug why my contact form isn't sending emails through Resend

Your form validates, sends email, and gives the user clear feedback at every stage. Next up, you'll deploy the entire system to Vercel with proper environment variable segregation.

Finalize the Vercel CI/CD Pipeline

You have a fully featured app running locally. Dynamic SEO metadata is pulling from Sanity, and the contact form is sending real emails through Resend.

Now it is time to ship everything to production. In this step, you will configure environment variables on Vercel, push your code to GitHub, and verify that both Production and Preview deployments work end to end through CI/CD.

In this step, get ready to:
  • Configure environment variables on Vercel for Production and Preview deployments.
  • Push all changes to GitHub and verify the production deployment.
  • Validate that Preview deployments generate correctly on feature branch pushes.
Configure environment variables on Vercel

Your app reads sensitive values like API keys through environment variables. Locally these live in .env.local, but Vercel needs its own copy for Production and Preview deployments.

Why configure variables separately on Vercel?

Vercel builds and runs your app on its own servers. It cannot read your local .env.local file. Each environment variable must be explicitly added to the Vercel dashboard so the build process and serverless functions can access them at runtime.

  • Return to your Vercel dashboard and select your project.
  • Select Environment Variables in the sidebar.

You will add four variables. For each one, enter the name, paste the value, select the appropriate environments, then click Save.

  • Add RESEND_API_KEY with your Resend API key value. Select Production and Preview environments only.
  • Add NEXT_PUBLIC_SANITY_PROJECT_ID with your Sanity project ID. Select all environments (Production, Preview, and Development).
  • Add NEXT_PUBLIC_SANITY_DATASET with the value production. Select all environments.
  • Add SANITY_API_READ_TOKEN with your Sanity read token. Select Production and Preview environments only.

Why are some variables missing the NEXT_PUBLIC_ prefix?

Variables without the NEXT_PUBLIC_ prefix are only available server-side. This keeps sensitive keys like RESEND_API_KEY and SANITY_API_READ_TOKEN hidden from the browser. They are only accessible inside Server Actions and server-side rendering functions.

Variables prefixed with NEXT_PUBLIC_ are bundled into the client-side JavaScript and visible to anyone inspecting your page source. Only use this prefix for non-sensitive values like your Sanity project ID and dataset name.

  • After saving all four variables, confirm they appear in the environment variables list with the correct environment badges.

Variable not showing the right environments?

Click the three-dot menu next to the variable name, select Edit, and verify the correct environment checkboxes are selected. Click Save again.

Help me fix my Vercel environment variable configuration.

Push to GitHub and verify the deployment pipeline

With environment variables in place, Vercel is ready to build your app. Pushing to your main branch triggers an automatic Production deployment.

  • Return to your terminal in your project directory.
  • Stage all your changes by running this command:
git add .
  • Commit your changes by running:
git commit -m "Add SEO metadata, contact form, and Resend integration"
  • Push to your main branch by running:
git push origin main

The push triggers a new Production deployment on Vercel automatically.

  • Return to your Vercel dashboard and select your project.
  • Watch the Deployments tab for a new build to appear.
  • Click into the deployment to monitor the build log.
  • Confirm the build completes successfully with no TypeScript errors and no warnings about missing environment variables.

What should I see in the build log?

A successful build shows a green checkmark with a production URL. The log should display route compilation for your pages including /posts/[slug] and /contact. If environment variables are missing, you would see warnings mentioning undefined or failed API calls during build.

Now verify that your production deployment works end to end.

  • Click the production URL that Vercel assigned to your deployment.
  • Navigate to one of your post pages.
  • Right-click the page and select View Page Source (or press Cmd+U on macOS, Ctrl+U on Windows).
  • Search for og:image in the source. Confirm that <title>, <meta name="description">, and <meta property="og:image"> are populated with your Sanity content.
  • Navigate to /contact on your production URL.
  • Submit the form with valid data and confirm an email arrives in your inbox.

Build failing or contact form not sending?

If the build fails with TypeScript errors, check the build log for the specific file and line number. A common cause is a missing import or mismatched type.

If the contact form does not send emails from production, verify that RESEND_API_KEY is set for the Production environment in Vercel. Remember that changes to environment variables require a redeployment to take effect.

Help me debug my Vercel production deployment.

Validate preview deployments

Preview deployments let you test changes on a unique URL before merging to production. Every push to a non-main branch generates one automatically.

  • Create a new feature branch by running:
git checkout -b test-preview
  • Make a small visible change to verify the Preview works. For example, in src/app/contact/page.tsx, change the heading text from Get in Touch to Get in Touch (Preview Test).
  • Commit and push the feature branch by running:
git add .
git commit -m "Test preview deployment"
git push origin test-preview
  • Return to your Vercel dashboard. A new Preview deployment should appear in the Deployments tab within a minute.
  • Click the Preview deployment URL.
  • Navigate to /contact and confirm your heading change is visible.
  • Submit the contact form on the Preview URL to confirm environment variables are working.

How is this different from Production?

Preview deployments get their own unique URL and use environment variables scoped to the Preview environment. This lets you test features in isolation before merging to main. If you scoped RESEND_API_KEY and SANITY_API_READ_TOKEN to both Production and Preview, both deployment types have full functionality.

  • After verifying the Preview deployment, switch back to your main branch by running:
git checkout main

Revert your test change

The test-preview branch was only for verification. You can delete it locally with git branch -d test-preview now that you have confirmed Preview deployments work.

Preview deployment not appearing?

Make sure your GitHub repository is connected to your Vercel project. Check that the push went through by running git log --oneline -1 and confirming your commit message appears.

If Vercel shows no new deployments, go to your project Settings and confirm the Git section shows your GitHub repository connected.

Help me fix my Vercel preview deployment.

Secret mission

Programmatic Sitemap Generation from Sanity

Your SEO metadata is live, but search engines still don't know which pages exist on your site. Generate a programmatic sitemap.xml that queries Sanity for every published slug and serves it as valid XML at a single URL.

Clean Up Your Resources

Clean Up Your Resources

This project uses Vercel's Hobby tier and Resend's free tier, so there are no ongoing costs while your site is live. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • Vercel Production deployment (Hobby tier, free).
  • Resend API key and email usage (free tier: 3,000 emails/month, 100 emails/day).
  • Vercel environment variables (RESEND_API_KEY, NEXT_PUBLIC_SANITY_PROJECT_ID, NEXT_PUBLIC_SANITY_DATASET, SANITY_API_READ_TOKEN).
  • Local .env.local file with your Resend API key.

Keep everything running

No action needed. Choose this if you are still actively building on this project or plan to iterate further.

  • Vercel Hobby tier does not charge for idle deployments. Your site stays live at your .vercel.app domain.
  • Resend's free tier allows 3,000 emails/month and 100 emails/day. As long as you stay within that limit, there are no charges.
  • Your environment variables on Vercel remain configured and ready for future deploys.

Pause - I'll come back to this later

Shut down running processes to free up local resources. Your Vercel deployment stays live at no cost.

  • Stop your local dev server if it is still running (press Ctrl+C in the terminal).
  • No action needed on Vercel. Hobby tier deployments do not charge for idle time.
  • No action needed on Resend. Your API key remains valid until manually deleted.

Delete - I don't want to use this again

Remove all project resources and start fresh. This is permanent.

  • Delete the Vercel project:
  • Open the Vercel dashboard and select your project.
  • Click Settings in the sidebar.
  • Scroll to the bottom of the General page and find the Delete Project section.
  • Click Delete, enter your project name to confirm, then click Continue.

This removes all deployments, domains, and environment variables associated with the project.

  • Revoke the Resend API key:
  • Open the Resend dashboard and navigate to the API Keys page.
  • Find the API key you created for this project.
  • Click the More options button next to it, then click Remove API Key.

Delete your local .env.local file or remove the RESEND_API_KEY line from it.

That's a Wrap!

That's a Wrap!

Nice work! You've just production-hardened a Next.js agency CMS with automated SEO, transactional email, and continuous deployment.

You've learned how to:

  • Implement programmatic SEO metadata using generateMetadata with GROQ queries against Sanity, producing dynamic titles, descriptions, and Open Graph images for every page.
  • Build a production contact form using Next.js Server Actions with Zod validation and useActionState for real-time error and pending UI feedback, delivering emails through the Resend SDK.
  • Configure a complete CI/CD pipeline on Vercel with proper environment variable segregation between Production and Preview deployments.
  • Secret Mission: Generated a programmatic sitemap.xml that dynamically queries Sanity for all published content URLs.

Ready to quiz yourself?