DEV Community

Cover image for Building a Next.js Landing Page with Google Sheets Integration and Google Cloud Run Deployment
Dale Nguyen
Dale Nguyen

Posted on

Building a Next.js Landing Page with Google Sheets Integration and Google Cloud Run Deployment

Building a modern landing page that captures potential user emails is essential for any startup or product launch. In this comprehensive guide, I'll walk you through creating a Next.js 15 landing page that collects emails in Google Sheets and deploys seamlessly to Google Cloud Run.

What We'll Build

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

  • A responsive Next.js 15 landing page with TypeScript
  • Email signup form with validation and loading states
  • Google Sheets integration to store collected emails
  • Dockerized application ready for production
  • Automated deployment to Google Cloud Run
  • Cost-effective, scalable infrastructure

Landing page example

Prerequisites

  • Node.js 20+ and pnpm installed
  • Google Cloud Platform account
  • Basic knowledge of React and TypeScript
  • Docker installed (for local testing)

Project Setup

1. Initialize the Next.js Project

# Create Next.js project with TypeScript
npx create-next-app@latest landing --typescript --tailwind --app

cd landing
Enter fullscreen mode Exit fullscreen mode

2. Install Dependencies

# Install Google APIs client
pnpm add googleapis

# Install development dependencies
pnpm add -D @types/node
Enter fullscreen mode Exit fullscreen mode

Building the Email Signup Component

1. Create the Email Signup Component

Create src/app/components/email-signup/email-signup.tsx:

'use client'

import { useState, useEffect } from 'react'

const EmailSignup = () => {
  const [email, setEmail] = useState('')
  const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle')
  const [message, setMessage] = useState('')

  const validateEmail = (email: string): boolean => {
    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    return emailRegex.test(email)
  }

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault()

    if (!validateEmail(email)) {
      setStatus('error')
      setMessage('Please enter a valid email address')
      return
    }

    setStatus('loading')
    setMessage('')

    try {
      const response = await fetch('/api/v1/email-signup', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({ email }),
      })

      if (response.ok) {
        setStatus('success')
        setMessage('Thank you for subscribing!')
        setEmail('')
      } else {
        throw new Error('Subscription failed')
      }
    } catch (error) {
      setStatus('error')
      setMessage('Something went wrong. Please try again.')
    }
  }

  return (
    <section className="relative py-24 bg-gradient-to-br from-blue-600 via-purple-600 to-indigo-700">
      <div className="container relative z-10">
        <div className="text-center max-w-2xl mx-auto">
          <h2 className="mb-6 text-3xl md:text-4xl font-bold text-white">Stay updated with the latest</h2>
          <p className="mb-10 text-xl text-blue-100">Get the latest updates delivered to your inbox.</p>

          <form onSubmit={handleSubmit} className="flex flex-col sm:flex-row gap-4 max-w-md mx-auto mb-8">
            <input
              type="email"
              required
              className="flex-1 px-6 py-4 text-lg bg-white/10 backdrop-blur-sm border border-white/20 rounded-2xl text-white placeholder:text-blue-200 focus:outline-none focus:ring-2 focus:ring-white/50"
              placeholder="Enter your email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              disabled={status === 'loading'}
            />
            <button
              type="submit"
              disabled={status === 'loading'}
              className="px-8 py-4 bg-white text-blue-600 rounded-2xl font-semibold hover:bg-blue-50 disabled:opacity-50 transition-all"
            >
              {status === 'loading' ? 'Subscribing...' : 'Subscribe'}
            </button>
          </form>

          {message && (
            <p className={`mb-6 text-lg font-medium ${status === 'success' ? 'text-green-400' : 'text-red-400'}`}>
              {message}
            </p>
          )}
        </div>
      </div>
    </section>
  )
}

export default EmailSignup
Enter fullscreen mode Exit fullscreen mode

Google Sheets Integration

1. Create Google Sheets Service

Create src/lib/google-sheets.ts:

import { google } from 'googleapis'

export interface EmailSignupData {
  email: string
  timestamp: string
  source?: string
}

class GoogleSheetsService {
  private auth
  private sheets

  constructor() {
    // Use Application Default Credentials (Cloud Run provides this automatically)
    this.auth = new google.auth.GoogleAuth({
      scopes: ['https://www.googleapis.com/auth/spreadsheets'],
    })

    this.sheets = google.sheets({ version: 'v4', auth: this.auth })
  }

  async saveEmailSignup(data: EmailSignupData): Promise<void> {
    const spreadsheetId = process.env.GOOGLE_SHEETS_ID

    if (!spreadsheetId) {
      throw new Error('Google Sheets ID not configured')
    }

    const values = [[data.email, data.timestamp, data.source || 'landing-page']]

    try {
      await this.sheets.spreadsheets.values.append({
        spreadsheetId,
        range: 'Sheet1!A:C',
        valueInputOption: 'USER_ENTERED',
        requestBody: {
          values,
        },
      })
    } catch (error) {
      console.error('Error saving to Google Sheets:', error)
      throw error
    }
  }

  async setupSheet(): Promise<void> {
    const spreadsheetId = process.env.GOOGLE_SHEETS_ID

    if (!spreadsheetId) {
      throw new Error('Google Sheets ID not configured')
    }

    try {
      // Add headers if sheet is empty
      const response = await this.sheets.spreadsheets.values.get({
        spreadsheetId,
        range: 'Sheet1!A1:C1',
      })

      if (!response.data.values || response.data.values.length === 0) {
        await this.sheets.spreadsheets.values.update({
          spreadsheetId,
          range: 'Sheet1!A1:C1',
          valueInputOption: 'USER_ENTERED',
          requestBody: {
            values: [['Email', 'Timestamp', 'Source']],
          },
        })
      }
    } catch (error) {
      console.error('Error setting up Google Sheets:', error)
      throw error
    }
  }
}

export const googleSheetsService = new GoogleSheetsService()
Enter fullscreen mode Exit fullscreen mode

2. Create API Route

Create src/app/api/v1/email-signup/route.ts:

import { NextRequest, NextResponse } from 'next/server'
import { googleSheetsService } from '../../../../lib/google-sheets'

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { email } = body

    // Validate email
    if (!email || typeof email !== 'string') {
      return NextResponse.json({ error: 'Email is required' }, { status: 400 })
    }

    const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
    if (!emailRegex.test(email)) {
      return NextResponse.json({ error: 'Invalid email format' }, { status: 400 })
    }

    // Save to Google Sheets
    try {
      await googleSheetsService.saveEmailSignup({
        email,
        timestamp: new Date().toISOString(),
        source: 'landing-waitlist',
      })
      console.log('Email saved to Google Sheets:', email)
    } catch (sheetsError) {
      console.error('Failed to save to Google Sheets:', sheetsError)
      // Continue with success response even if sheets fails
    }

    return NextResponse.json({ message: 'Email subscribed successfully' }, { status: 200 })
  } catch (error) {
    console.error('Email signup error:', error)
    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}
Enter fullscreen mode Exit fullscreen mode

Dockerization for Production

1. Configure Next.js for Standalone Output

Update next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  // Enable standalone output for Docker
  output: 'standalone',

  // Enable compression in production
  compress: true,

  // Optimize for production
  experimental: {
    optimizePackageImports: ['lucide-react'],
  },
}

module.exports = nextConfig
Enter fullscreen mode Exit fullscreen mode

2. Create Dockerfile

Create Dockerfile:

# Minimal production image for built Next.js app
FROM node:20-alpine AS production

WORKDIR /app

# Copy the standalone build (includes dependencies, server, and app)
COPY .next/standalone/ ./

# Copy static files
COPY public ./public
COPY .next/static ./.next/static

# Set environment
ENV NODE_ENV=production
ENV PORT=3000

# Create a non-root user
RUN addgroup -g 1001 -S nodejs && \
    adduser -S nextjs -u 1001

# Change ownership of the application files
RUN chown -R nextjs:nodejs /app

USER nextjs

# Expose port
EXPOSE 3000

# Start the standalone server
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

3. Build and Test Locally

# Build the Next.js application
npm run build

# Build Docker image
docker build -t landing-page .

# Test locally
docker run -p 3000:3000 -e GOOGLE_SHEETS_ID=your-sheet-id landing-page
Enter fullscreen mode Exit fullscreen mode

Google Cloud Setup

1. Create Google Cloud Resources

# Login to Google Cloud
gcloud auth login

# Set your project
gcloud config set project YOUR-PROJECT-ID

# Enable required APIs
gcloud services enable run.googleapis.com
gcloud services enable sheets.googleapis.com

# Create a service account
gcloud iam service-accounts create workwrite-sheets \
    --display-name="WorkWrite Sheets Service Account"

# Grant Google Sheets access
gcloud projects add-iam-policy-binding YOUR-PROJECT-ID \
    --member="serviceAccount:workwrite-sheets@YOUR-PROJECT-ID.iam.gserviceaccount.com" \
    --role="roles/editor"
Enter fullscreen mode Exit fullscreen mode

2. Create Google Sheet

  1. Go to Google Sheets
  2. Create a new spreadsheet
  3. Copy the spreadsheet ID from the URL
  4. Share the sheet with your service account email

3. Build and Push to Google Cloud Registry

# Configure Docker to use gcloud
gcloud auth configure-docker us-central1-docker.pkg.dev

# Create Artifact Registry repository
gcloud artifacts repositories create workwrite-repo \
    --repository-format=docker \
    --location=us-central1

# Build and tag image
docker build --platform linux/amd64 -t us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing .

# Push to registry
docker push us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing
Enter fullscreen mode Exit fullscreen mode

Deployment to Google Cloud Run

1. Deploy with Cloud Run

gcloud run deploy workwrite-landing \
  --image us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing \
  --region us-central1 \
  --platform managed \
  --allow-unauthenticated \
  --service-account workwrite-sheets@YOUR-PROJECT-ID.iam.gserviceaccount.com \
  --set-env-vars GOOGLE_SHEETS_ID=YOUR-SHEET-ID \
  --port 3000 \
  --memory 1Gi \
  --cpu 1 \
  --max-instances 10 \
  --timeout 300
Enter fullscreen mode Exit fullscreen mode

2. Automated Deployment with Scripts

Create deployment scripts in your package.json:

{
  "scripts": {
    "docker:build": "docker build --platform linux/amd64 -t us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing .",
    "docker:push": "docker push us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing",
    "deploy": "npm run build && npm run docker:build && npm run docker:push && gcloud run deploy workwrite-landing --image us-central1-docker.pkg.dev/YOUR-PROJECT-ID/workwrite-repo/workwrite-landing --region us-central1"
  }
}
Enter fullscreen mode Exit fullscreen mode

Advanced Features

1. Environment Variables

Create .env.local for development:

GOOGLE_SHEETS_ID=your-development-sheet-id
Enter fullscreen mode Exit fullscreen mode

2. Error Handling and Monitoring

Add comprehensive error handling in your API routes:

export async function POST(request: NextRequest) {
  try {
    // ... your existing code
  } catch (error) {
    // Log error for monitoring
    console.error('Email signup error:', {
      error: error.message,
      timestamp: new Date().toISOString(),
      userAgent: request.headers.get('user-agent'),
    })

    return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Rate Limiting

Implement rate limiting to prevent abuse:

import { NextRequest, NextResponse } from 'next/server'

const rateLimitMap = new Map()

export async function POST(request: NextRequest) {
  const ip = request.ip || 'unknown'
  const limit = 5 // 5 requests per minute
  const windowMs = 60 * 1000 // 1 minute

  if (!rateLimitMap.has(ip)) {
    rateLimitMap.set(ip, {
      count: 0,
      lastReset: Date.now(),
    })
  }

  const user = rateLimitMap.get(ip)

  if (Date.now() - user.lastReset > windowMs) {
    user.count = 0
    user.lastReset = Date.now()
  }

  if (user.count >= limit) {
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
  }

  user.count += 1

  // ... rest of your code
}
Enter fullscreen mode Exit fullscreen mode

Performance Optimizations

1. Image Optimization

Add optimized images with Next.js Image component:

import Image from 'next/image'

const Hero = () => (
  <section className="hero">
    <Image src="/hero-image.jpg" alt="Hero" width={800} height={600} priority className="rounded-lg" />
  </section>
)
Enter fullscreen mode Exit fullscreen mode

2. SEO Optimization

Update src/app/layout.tsx with comprehensive metadata:

import type { Metadata } from 'next'

export const metadata: Metadata = {
  title: 'WorkWrite - AI-Powered Writing Platform',
  description: 'Transform your writing workflow with AI.',
  keywords: ['writing', 'AI', 'productivity'],
  openGraph: {
    type: 'website',
    locale: 'en_US',
    url: 'https://workwrite.com',
    title: 'WorkWrite - AI-Powered Writing Platform',
    description: 'Transform your writing workflow with AI.',
    images: [
      {
        url: '/og-image.jpg',
        width: 1200,
        height: 630,
        alt: 'WorkWrite',
      },
    ],
  },
}
Enter fullscreen mode Exit fullscreen mode

Cost Optimization

1. Cloud Run Pricing

Google Cloud Run pricing is based on:

  • CPU and Memory: Pay per 100ms of usage
  • Requests: $0.40 per million requests
  • Networking: Egress charges apply

For a typical landing page:

  • Low traffic (1K visitors/month): ~$1-2/month
  • Medium traffic (10K visitors/month): ~$5-10/month
  • High traffic (100K visitors/month): ~$20-40/month

2. Optimization Tips

// next.config.js
const nextConfig = {
  // Compress responses
  compress: true,

  // Optimize bundle
  experimental: {
    optimizePackageImports: ['lucide-react'],
  },

  // Cache static assets
  headers: async () => [
    {
      source: '/(.*)\\.(js|css|png|jpg|jpeg|gif|ico|svg)',
      headers: [
        {
          key: 'Cache-Control',
          value: 'public, max-age=31536000, immutable',
        },
      ],
    },
  ],
}
Enter fullscreen mode Exit fullscreen mode

Monitoring and Analytics

1. Google Analytics

Add Google Analytics to track conversions:

// src/app/layout.tsx
import Script from 'next/script'

export default function RootLayout({ children }) {
  return (
    <html>
      <head>
        <Script src="https://www.googletagmanager.com/gtag/js?id=GA_MEASUREMENT_ID" strategy="afterInteractive" />
        <Script id="google-analytics" strategy="afterInteractive">
          {`
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag('js', new Date());
            gtag('config', 'GA_MEASUREMENT_ID');
          `}
        </Script>
      </head>
      <body>{children}</body>
    </html>
  )
}
Enter fullscreen mode Exit fullscreen mode

2. Conversion Tracking

Track email signups:

// Declare gtag as a global function for TypeScript
declare global {
  interface Window {
    gtag: (
      command: 'config' | 'event' | 'js',
      targetId: string | Date,
      config?: {
        event_category?: string
        event_label?: string
        value?: number
        [key: string]: any
      },
    ) => void
  }
}

// In your email signup component
const trackConversion = () => {
  if (typeof gtag !== 'undefined') {
    gtag('event', 'sign_up', {
      event_category: 'engagement',
      event_label: 'email_signup',
    })
  }
}

const handleSubmit = async (e) => {
  // ... existing code

  if (response.ok) {
    trackConversion()
    // ... rest of success handling
  }
}
Enter fullscreen mode Exit fullscreen mode

Security Best Practices

1. Environment Variables

Never commit sensitive data:

# .env.local (never commit)
GOOGLE_SHEETS_ID=your-actual-sheet-id

# For production, set in Cloud Run
gcloud run services update workwrite-landing \
  --set-env-vars GOOGLE_SHEETS_ID=your-production-sheet-id
Enter fullscreen mode Exit fullscreen mode

2. Input Validation

Always validate user inputs:

import { z } from 'zod'

const emailSchema = z.object({
  email: z.string().email('Invalid email format'),
})

export async function POST(request: NextRequest) {
  try {
    const body = await request.json()
    const { email } = emailSchema.parse(body)

    // ... rest of your code
  } catch (error) {
    if (error instanceof z.ZodError) {
      return NextResponse.json({ error: error.errors[0].message }, { status: 400 })
    }
    // ... handle other errors
  }
}
Enter fullscreen mode Exit fullscreen mode

Testing

1. Component Testing

// __tests__/email-signup.test.tsx
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import EmailSignup from '../src/app/components/email-signup/email-signup'

describe('EmailSignup', () => {
  test('submits email successfully', async () => {
    global.fetch = jest.fn(() =>
      Promise.resolve({
        ok: true,
        json: () => Promise.resolve({ message: 'Success' }),
      }),
    )

    render(<EmailSignup />)

    const input = screen.getByPlaceholderText('Enter your email')
    const button = screen.getByRole('button', { name: /subscribe/i })

    fireEvent.change(input, { target: { value: 'test@example.com' } })
    fireEvent.click(button)

    await waitFor(() => {
      expect(screen.getByText('Thank you for subscribing!')).toBeInTheDocument()
    })
  })
})
Enter fullscreen mode Exit fullscreen mode

2. API Testing

// __tests__/api/email-signup.test.ts
import { POST } from '../../src/app/api/v1/email-signup/route'

describe('/api/v1/email-signup', () => {
  test('validates email format', async () => {
    const request = new Request('http://localhost', {
      method: 'POST',
      body: JSON.stringify({ email: 'invalid-email' }),
    })

    const response = await POST(request)
    const data = await response.json()

    expect(response.status).toBe(400)
    expect(data.error).toBe('Invalid email format')
  })
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

You now have a complete, production-ready Next.js landing page that:

  • Collects emails efficiently with a beautiful UI
  • Stores data in Google Sheets automatically
  • Scales automatically with Google Cloud Run
  • Costs minimal for low to medium traffic
  • Performs excellently with optimized builds
  • Monitors effectively with built-in analytics

This setup provides a solid foundation for any product launch or marketing campaign. The combination of Next.js 15, Google Sheets, and Cloud Run offers excellent developer experience with enterprise-grade scalability and reliability.

Next Steps

  1. Add A/B testing for different signup copy
  2. Implement email automation with services like SendGrid
  3. Add more landing page sections (testimonials, features, pricing)
  4. Set up CI/CD with GitHub Actions
  5. Add real-time analytics dashboard

The architecture we've built is flexible and can grow with your product needs while maintaining excellent performance and cost efficiency.

Top comments (0)