Skip to main content
Shipnative includes a marketing landing page built with Vite + React + Tailwind CSS. Use it to collect emails before launch (waitlist mode) or showcase your app after it’s live (launch mode).
For vibecoders: yarn setup configures this for you. Just run yarn web to see your marketing page at localhost:5173.
Which landing page is this?
  • apps/web/ - Your app’s marketing page (this guide)
  • landing_page/ - Shipnative’s own marketing site (not yours)

Two Modes

The marketing page has two modes you can switch between:

Waitlist Mode (Pre-Launch)

Perfect for collecting early user emails before your app launches. Features:
  • Email capture form with validation
  • Success confirmation message
  • App screenshot showcase
  • Feature bento grid with 6 tiles:
    • AI-first architecture
    • Mock mode
    • Premium design system
    • Monetization
    • Analytics & reliability
    • Optimized dev loop
  • AI tools integration section (Cursor, Claude, Copilot, etc.)
  • Fully responsive design
  • SEO optimized
When to use: Building in public, collecting beta testers, pre-launch hype

Launch Mode (Post-Launch)

Perfect for when your app is live on the App Store and Google Play. Features:
  • App Store and Google Play badge links
  • Hero section with app name and tagline
  • Feature highlights (3 key benefits)
  • Clean, minimal design focused on downloads
  • SEO optimized
When to use: App is live and you want to drive downloads

️ Quick Setup

yarn setup now also asks for the marketing page mode. It writes apps/web/.env for you, so you can skip to Step 3 if you just ran it.Important: The Resend API key is now stored securely in Supabase Edge Function secrets, not in the frontend .env file. See the “Adding a Waitlist API” section below for setup instructions.

Step 1: Copy Environment Variables (if you skipped yarn setup)

cp apps/web/.env.example apps/web/.env

Step 2: Configure Your Settings

Edit apps/web/.env (or let yarn setup fill it):
# Choose mode: 'waitlist' or 'launch'
VITE_MODE=waitlist

# Your app details
VITE_APP_NAME=YourApp
VITE_APP_DESCRIPTION=Your app description goes here
VITE_APP_URL=https://yourapp.com

# SEO & Social
VITE_OG_IMAGE=https://yourapp.com/og-image.jpg

# Waitlist Mode Settings
VITE_APP_SCREENSHOT_URL=/app-screenshot.png
VITE_WAITLIST_API_ENDPOINT=https://your-project-ref.supabase.co/functions/v1/waitlist  # Supabase Edge Function endpoint

# Launch Mode Settings (required only in launch mode)
VITE_IOS_APP_URL=https://apps.apple.com/app/your-app
VITE_ANDROID_APP_URL=https://play.google.com/store/apps/details?id=com.yourapp

Step 3: Switch Modes Quickly

yarn marketing:waitlist   # sets VITE_MODE=waitlist in apps/web/.env
yarn marketing:launch     # sets VITE_MODE=launch in apps/web/.env
# or: yarn marketing:mode <waitlist|launch>

Step 4: Run the Development Server

yarn web        # alias for `yarn marketing:dev`
# or: yarn marketing:dev
Visit http://localhost:5173 to see your marketing page!

Customization

Switching Between Modes

Use the helper command (it updates apps/web/.env for you):
yarn marketing:mode waitlist   # Pre-launch waitlist
yarn marketing:mode launch     # Post-launch store links
# Aliases: yarn marketing:waitlist / yarn marketing:launch
Prefer to edit manually? Change VITE_MODE in apps/web/.env. No code changes needed - the app automatically renders the correct mode!

Customizing Waitlist Mode

Edit /apps/web/src/components/WaitlistMode.tsx: Change feature tiles (lines 92-164):
const bentoTiles: BentoTile[] = [
  {
    title: 'Your Feature Title',
    description: 'Feature description here',
    icon: '',
    pill: 'Optional badge',
    accent: 'from-[#E0F2FE] via-white to-[#C7E9FF]',  // Gradient colors
    span: 'lg:col-span-2',  // Optional: make tile wider
    points: [
      'Key benefit 1',
      'Key benefit 2',
    ],
    metric: {  // Optional: add a metric
      label: 'to launch',
      value: '< 1 hr',
    },
  },
  // Add more tiles...
]
Change AI tools (lines 33-90):
const aiTools: AiTool[] = [
  {
    name: 'Tool Name',
    url: 'https://tool.com',
    domain: 'tool.com',
    logoText: 'TN',  // 1-2 letter abbreviation
    logoGradient: 'from-[#0C163D] via-[#0F4C75] to-[#2DD4BF]',
    tagline: 'Short description of integration',
  },
  // Add more tools...
]
Add your app screenshot:
  1. Place your screenshot in /apps/web/public/app-screenshot.png
  2. Or update VITE_APP_SCREENSHOT_URL to use a URL

Customizing Launch Mode

Edit /apps/web/src/components/LaunchMode.tsx: Change feature highlights (lines 92-108):
<div className="space-y-2">
  <div className="text-3xl"></div>
  <h3 className="font-semibold text-gray-900">Your Feature</h3>
  <p className="text-sm text-gray-500">Feature description</p>
</div>
Customize colors and styling: The landing page uses Tailwind CSS. Edit component files to change:
  • Colors: bg-blue-600, text-gray-900, etc.
  • Spacing: p-4, m-8, gap-6, etc.
  • Layout: Grid, flex, responsive breakpoints

Adding a Waitlist API with Supabase Edge Function

The waitlist form is already configured to use a Supabase Edge Function endpoint. This keeps your Resend API key secure on the server side. Step 1: Create the Waitlist Table The waitlist table SQL is already included in supabase/schema.sql. You have two options: Option A: Run the full schema (Recommended) If you haven’t set up your Supabase database yet, run the entire supabase/schema.sql file in your Supabase SQL Editor. It includes the waitlist table along with all other default tables. Option B: Run just the waitlist table SQL If you’ve already set up your database and just need the waitlist table, run this SQL in your Supabase SQL Editor:
-- Waitlist table for storing email addresses
CREATE TABLE IF NOT EXISTS public.waitlist (
    id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
    email TEXT NOT NULL UNIQUE,
    source TEXT DEFAULT 'marketing_page',
    user_agent TEXT,
    ip_address TEXT,
    email_sent BOOLEAN DEFAULT false,
    email_sent_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Enable Row Level Security
ALTER TABLE public.waitlist ENABLE ROW LEVEL SECURITY;

-- Allow anonymous inserts (for waitlist form submissions)
CREATE POLICY "Anyone can add to waitlist"
    ON public.waitlist
    FOR INSERT
    WITH CHECK (true);

-- Allow authenticated users to view waitlist
CREATE POLICY "Authenticated users can view waitlist"
    ON public.waitlist
    FOR SELECT
    USING (auth.role() = 'authenticated');

-- Create indexes
CREATE INDEX IF NOT EXISTS waitlist_email_idx ON public.waitlist(email);
CREATE INDEX IF NOT EXISTS waitlist_created_at_idx ON public.waitlist(created_at DESC);
Step 2: Deploy the Edge Function
  1. Install Supabase CLI (if not already installed):
yarn global add supabase
# or: npm install -g supabase
  1. Login to Supabase:
supabase login
  1. Link your project:
supabase link --project-ref your-project-ref
  1. Initialize Supabase in your project (if not already done):
supabase init
  1. Create the edge function:
supabase functions new waitlist
This creates supabase/functions/waitlist/index.ts. Replace its contents with this code:
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'

const corsHeaders = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
}

serve(async (req) => {
  if (req.method === 'OPTIONS') {
    return new Response('ok', { headers: corsHeaders })
  }

  try {
    const supabaseUrl = Deno.env.get('SUPABASE_URL')!
    const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
    const supabase = createClient(supabaseUrl, supabaseServiceKey)

    const resendApiKey = Deno.env.get('RESEND_API_KEY')
    const { email } = await req.json()

    if (!email || typeof email !== 'string' || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
      return new Response(
        JSON.stringify({ error: 'Invalid email address' }),
        { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    const userAgent = req.headers.get('user-agent') || ''
    const ipAddress = req.headers.get('x-forwarded-for')?.split(',')[0] || ''

    const { data: waitlistEntry, error: dbError } = await supabase
      .from('waitlist')
      .upsert(
        {
          email: email.toLowerCase().trim(),
          source: 'marketing_page',
          user_agent: userAgent,
          ip_address: ipAddress,
        },
        { onConflict: 'email', ignoreDuplicates: false }
      )
      .select()
      .single()

    if (dbError) {
      return new Response(
        JSON.stringify({ error: 'Failed to save email' }),
        { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
      )
    }

    // Send confirmation email via Resend (if configured)
    let emailSent = false
    if (resendApiKey && waitlistEntry) {
      try {
        const resendResponse = await fetch('https://api.resend.com/emails', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
            'Authorization': `Bearer ${resendApiKey}`,
          },
          body: JSON.stringify({
            from: Deno.env.get('RESEND_FROM_EMAIL') || 'onboarding@resend.dev',
            to: email,
            subject: Deno.env.get('RESEND_WAITLIST_SUBJECT') || 'Thanks for joining our waitlist!',
            html: Deno.env.get('RESEND_WAITLIST_HTML') || `
              <h1>Thanks for joining our waitlist!</h1>
              <p>We'll notify you when we launch.</p>
            `,
          }),
        })

        if (resendResponse.ok) {
          emailSent = true
          await supabase
            .from('waitlist')
            .update({ email_sent: true, email_sent_at: new Date().toISOString() })
            .eq('id', waitlistEntry.id)
        }
      } catch (emailError) {
        console.error('Email sending error:', emailError)
      }
    }

    return new Response(
      JSON.stringify({ success: true, message: 'Email added to waitlist', email_sent: emailSent }),
      { status: 200, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  } catch (error) {
    return new Response(
      JSON.stringify({ error: error.message || 'Internal server error' }),
      { status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }
    )
  }
})
  1. Deploy the edge function:
supabase functions deploy waitlist
  1. Set environment secrets in Supabase Dashboard:
    • Go to Project SettingsEdge FunctionsSecrets
    • Add RESEND_API_KEY (your Resend API key)
    • Add RESEND_FROM_EMAIL (optional, e.g., noreply@yourdomain.com)
    • Add RESEND_WAITLIST_SUBJECT (optional, custom subject line)
    • Add RESEND_WAITLIST_HTML (optional, custom HTML email template)
Step 3: Configure the Frontend Set the edge function endpoint in apps/web/.env:
VITE_WAITLIST_API_ENDPOINT=https://your-project-ref.supabase.co/functions/v1/waitlist
Replace your-project-ref with your actual Supabase project reference (found in your Supabase dashboard URL). That’s it! The waitlist form will now:
  • Save emails to your Supabase database
  • Send confirmation emails via Resend (if configured)
  • Keep your Resend API key secure on the server

Deployment

  1. Push your code to GitHub
  2. Go to vercel.com
  3. Import your repository
  4. Set Root Directory to apps/web
  5. Add environment variables from your .env file
  6. Deploy!
The landing page includes a vercel.json configuration file for easy deployment.

Deploy to Netlify

  1. Go to netlify.com
  2. Import your repository
  3. Set Base directory to apps/web
  4. Set Build command to yarn build
  5. Set Publish directory to apps/web/dist
  6. Add environment variables
  7. Deploy!

Other Platforms

The marketing page is a standard Vite app, so it works with:
  • Cloudflare Pages
  • GitHub Pages
  • AWS Amplify
  • Any static host
Build command: yarn build (from apps/web/) Output directory: dist/

SEO Configuration

The landing page includes SEO components that set:
  • Meta title and description
  • Open Graph tags (for social sharing)
  • Twitter cards
  • Canonical URL
Configure these in your .env:
VITE_APP_NAME=YourApp  # Used as page title
VITE_APP_DESCRIPTION=Your description  # Used in meta description
VITE_OG_IMAGE=https://yourapp.com/og-image.jpg  # Social share image
VITE_APP_URL=https://yourapp.com  # Canonical URL
Pro Tip: Create an Open Graph image (1200x630px) for better social sharing. Place it in /apps/web/public/og-image.jpg.

vs. Main Landing Page Repo

You might be wondering: “What’s the difference between apps/web and the landing_page repo?”
Featureapps/weblanding_page
PurposeYour app’s marketing pageShipnative’s own marketing
IncludedYes, in the monorepoSeparate repository
CustomizationYou own it - edit freelyDon’t edit (it’s for Shipnative)
Use casePre-launch waitlist or launch pageShipnative product marketing
DeployYour domainshipnative.com
TL;DR: Use apps/web for YOUR app. Ignore landing_page (unless you’re contributing to Shipnative itself).

Tips & Best Practices

Pre-Launch Strategy

  1. Start in waitlist mode while building
  2. Share the link on Twitter, Product Hunt, Reddit
  3. Collect 100-1000+ emails before launch
  4. Send launch announcement when app goes live

Launch Day

  1. Switch to launch mode (VITE_MODE=launch)
  2. Add your App Store / Play Store URLs
  3. Redeploy
  4. Email your waitlist!

A/B Testing

Want to test different copy? Create multiple deployments:
  • waitlist-v1.yourapp.com
  • waitlist-v2.yourapp.com
Deploy the same code with different env vars and see which converts better.

Custom Domain

  1. Buy a domain (Namecheap, Google Domains, etc.)
  2. Point it to your Vercel/Netlify deployment
  3. Configure in your hosting dashboard
  4. Update VITE_APP_URL to match

Troubleshooting

  • Make sure your .env file is in apps/web/, not the monorepo root
  • Restart the dev server after changing .env
  • Vite requires VITE_ prefix for all public env vars
  • Check for typos in variable names
  • Verify the image exists in /apps/web/public/
  • Check that VITE_APP_SCREENSHOT_URL matches your filename
  • Try using an absolute URL instead of a relative path
  • Clear browser cache
  • Check browser console for errors
  • Verify VITE_WAITLIST_API_ENDPOINT is correctly set
  • Test your API endpoint with Postman/Insomnia first
  • Check CORS settings on your API
  • Run yarn install in apps/web/
  • Verify tailwind.config.cjs exists
  • Check that src/index.css is imported in src/main.tsx
  • Try yarn build to see if it’s a dev-only issue

Next Steps