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 is included in the default Supabase schema. You have three options: Option A: Use Migrations (Recommended) The initial migration already includes the waitlist table:
# Link your project (one-time)
supabase link --project-ref your-project-ref

# Apply all migrations
supabase db push
Option B: Run the full schema If you prefer not to use migrations, run the entire supabase/schema.sql file in your Supabase SQL Editor. It includes the waitlist table along with all other default tables. Option C: 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

Deploy Your Page

Get your landing page live on Vercel in 5 minutes

Build Your App

Start building your actual mobile app with vibecoding

Analytics Setup

Track waitlist signups and page views with PostHog

Custom Domain

Point your domain to your landing page