I’ve spoken with a lot of you over the past few weeks around what they’re working on or goals for their wifi money.
A lot are stuck with hundreds of ideas and no execution. As
Because of that, we’re going to be running through building a micro-SaaS project from Zero - then give away the source code for free.
Honestly once you have a reasonable understanding of the language you’re working with, this isn’t so hard, and you always have AI backing you up if there is something you can’t figure out.
This is part one on a new series where we will be doing this over and over - you just need to execute.
The full source code from this project can be found here: https://www.swipesaas.com
The final project is live here: https://www.instantaiheadshots.com
The Plan
We’re going to be building an AI head shot generator that can take in a photo of you, and output a professional head shot you can put on your LinkedIn.
Starting with the tech:
Language: Typescript/Nextjs
AI Model Source: Replicate
Image Storage: UploadThing
Database: MongoDB
Payments: Stripe
Auth: Next-Auth / Google
Hosting: Vercel
Table of Contents:
Project Setup
Create Nextjs App
Git Setup
Landing Page
Basic Structure
Components
TopNav & Footer
Database Setup
Lead Collection
App Interface
Authentication
Image Upload
AI Image Generation
Layout & Past Generations
Payment Processing
Project Setup
Setting up the project is simple - just a few things to get you going.
Start by opening up a terminal or your favorite IDE, navigating to the directory you want your project to be in, and enter the following commands.
Note: I’ll be using Bun, but feel free to use npm or whatever is your preference.
bun create next-app@latest <you-app-name>
Example: bun create next-app@latest aiheadshot
Choose the following options in the wizard that follows:
Typescript? Yes
ESLint? Yes
Tailwind CSS? Yes
src/ directory? No
App Router? Yes
Customize the default import alias? No
cd <your-app-name>
git init
gh repo create
choose:
Push an existing local repository to github
.
Repo Name: Leave blank
Description: This is optional, leave blank or write a description
Visibility: Private
Add a remote? Y
What should the new remote be called? Leave blank
Would you like to push commits from the current branch to origin? Y
You now have a next project, and a remote git repository for version control.
Run: bun dev
this will get the project running locally so you can see what we’re working on.
Landing Page
We’re going to start a little bit differently here and simply build a landing page and a way of collecting leads. I am a strong believer in testing your ideas before investing large amounts of time and effort into the project. So although we are going to build the full project in this, its a good habit to get into to start with the landing page and leads.
Your project is made with Nextjs, so the app/page.tsx will be the page people load when they go to your website. At the moment it has the create next app boilerplate code, lets start by editing that.
Let’s create a basic layout of what we think our landing page should look like.
Hero/Summary
Call to action 1
Social Proof 1
Benefits
Call to action 2
Features
Social Proof 2
Call to action 3
Replace your app/page.tsx with this:
// app/page.tsx
import React from 'react';
import Hero from '../components/Hero';
import CallToAction from '../components/CTA';
import SocialProof from '../components/SocialProof';
import Benefits from '../components/Benefits';
import Features from '../components/Features';
export default function Home() {
return (
<main className="font-sans">
<Hero />
<CallToAction />
<SocialProof />
<Benefits />
<Features />
<CTA />
</main>
);
}
This the the basic structure of our landing page, now lets go into building each of these individual components. Notice there is no pricing section - for now we’re building this as if you’re collecting leads pre-launch.
We will add a pricing section when we add payments into this project.
So now create a folder called “components” in your project directory, then create Hero.tsx and we’ll start building.
// components/Hero.tsx
import React from 'react';
const Hero: React.FC = () => {
return (
<section className="bg-gradient-to-r from-indigo-600 to-purple-600 text-white py-24">
<div className="container mx-auto px-4 text-center">
<h1 className="text-5xl font-extrabold mb-6 leading-tight">
Transform Your Professional Image with AI
</h1>
<p className="text-xl mb-10 max-w-2xl mx-auto">
Generate stunning, business-ready headshots in seconds. Perfect for LinkedIn, resumes, and more!
</p>
<button className="bg-white text-indigo-600 px-8 py-4 rounded-full text-lg font-bold hover:bg-indigo-100 transition duration-300 shadow-lg">
Generate Your Headshot
</button>
</div>
</section>
);
};
export default Hero;
// components/CTA.tsx
import React from 'react';
interface CallToActionProps {
text: string;
buttonText: string;
}
const CallToAction: React.FC<CallToActionProps> = ({ text, buttonText }) => {
return (
<section className="bg-gray-100 py-20">
<div className="container mx-auto px-4 text-center">
<h2 className="text-3xl font-bold mb-6 text-gray-800">{text}</h2>
<button className="bg-indigo-600 text-white px-8 py-4 rounded-full text-lg font-bold hover:bg-indigo-700 transition duration-300 shadow-lg">
{buttonText}
</button>
</div>
</section>
);
};
export default CallToAction;
// components/SocialProof.tsx
import React from 'react';
interface Testimonial {
name: string;
role: string;
text: string;
}
interface SocialProofProps {
testimonials: Testimonial[];
}
const SocialProof: React.FC<SocialProofProps> = ({ testimonials }) => {
return (
<section className="bg-white py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold mb-12 text-center text-gray-800">What Our Users Say</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-10">
{testimonials.map((testimonial, index) => (
<div key={index} className="bg-gray-100 p-8 rounded-lg shadow-md">
<p className="mb-6 italic text-gray-600">"{testimonial.text}"</p>
<div className="flex items-center">
<div className="w-12 h-12 bg-indigo-500 rounded-full flex items-center justify-center text-white font-bold text-xl mr-4">
{testimonial.name[0]}
</div>
<div>
<p className="font-bold text-gray-800">{testimonial.name}</p>
<p className="text-sm text-gray-600">{testimonial.role}</p>
</div>
</div>
</div>
))}
</div>
</div>
</section>
);
};
export default SocialProof;
// components/Benefits.tsx
import React from 'react';
const Benefits: React.FC = () => {
const benefits = [
"Professional-looking headshots in seconds",
"No photography skills required",
"Perfect for LinkedIn, resumes, and professional profiles",
"Affordable alternative to professional photo shoots"
];
return (
<section className="bg-indigo-100 py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold mb-12 text-center text-gray-800">Benefits</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
{benefits.map((benefit, index) => (
<div key={index} className="flex items-start bg-white p-6 rounded-lg shadow-md h-24">
<svg className="h-8 w-8 text-indigo-500 mr-4 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
<span className="text-lg text-gray-700 max-w-xs">{benefit}</span>
</div>
))}
</div>
</div>
</section>
);
};
export default Benefits;
// components/Features.tsx
import React from 'react';
const Features: React.FC = () => {
const features = [
{ title: "AI-Powered Enhancement", description: "Our advanced AI algorithms optimize lighting, background, and facial features." },
{ title: "Multiple Styles", description: "Choose from various professional styles to match your industry and personal brand." },
{ title: "Quick Turnaround", description: "Get your professional headshot in less than 5 minutes." },
{ title: "Easy-to-Use Interface", description: "Simple upload and editing process, no technical skills required." }
];
return (
<section className="bg-white py-20">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold mb-12 text-center text-gray-800">Features</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
{features.map((feature, index) => (
<div key={index} className="bg-gray-100 p-8 rounded-lg shadow-md">
<h3 className="text-xl font-bold mb-4 text-indigo-600">{feature.title}</h3>
<p className="text-gray-700">{feature.description}</p>
</div>
))}
</div>
</div>
</section>
);
};
export default Features;
Thats it, now we have all the components ready, there are a few changes we’ll need to make to the landing page to make it work, going back to app/page.tsx, we’re going to fill out the extra details we need to make the components we wrote work.
CallToAction needs Text and buttonText
SocialProof needs testimonials passed in as an array of lists, each list contains name, role, and text.
// app/page.tsx
import React from 'react';
import Hero from '../components/Hero';
import CallToAction from '../components/CTA';
import SocialProof from '../components/SocialProof';
import Benefits from '../components/Benefits';
import Features from '../components/Features';
export default function Home() {
return (
<main className="font-sans">
<Hero />
<CallToAction
text="Ready to upgrade your professional image?"
buttonText="Generate Your Headshot"
/>
<SocialProof
testimonials={[
{ name: "John Doe", role: "Marketing Manager", text: "This AI headshot generator saved me time and money. The results are impressive!" },
{ name: "Jane Smith", role: "Software Engineer", text: "I was skeptical at first, but the quality of the headshots exceeded my expectations." },
{ name: "Mike Johnson", role: "Freelance Consultant", text: "As a freelancer, this tool helps me maintain a professional image across all platforms." }
]}
/>
<Benefits />
<CallToAction
text="Join thousands of professionals who've upgraded their image"
buttonText="Start Now"
/>
<Features />
<SocialProof
testimonials={[
{ name: "Sarah Lee", role: "HR Specialist", text: "We use this for all our employee profiles. It's consistent and professional." },
{ name: "Tom Brown", role: "Recent Graduate", text: "This tool helped me create a great first impression for job applications." },
{ name: "Emily Chen", role: "Entrepreneur", text: "Quick, easy, and effective. Perfect for busy professionals like me." }
]}
/>
<CallToAction
text="Ready to transform your professional image?"
buttonText="Generate Your Headshot Now"
/>
</main>
);
}
That is the basic landing page done, two last things we want to add are a Top Navigation Bar and a Footer.
Create both as components:
// components/TopNav.tsx
import React from 'react';
const TopNav: React.FC = () => {
return (
<nav className="bg-white shadow-md">
<div className="container mx-auto px-6 py-3 flex justify-between items-center">
<div className="flex items-center">
<span className="text-xl font-bold text-gray-800">YourLogo</span>
</div>
<div className="hidden md:flex items-center space-x-8">
<a href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Home</a>
<a href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Features</a>
<a href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Pricing</a>
<a href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Contact</a>
</div>
<div className="md:hidden">
<button className="text-gray-800 hover:text-indigo-600 focus:outline-none">
<svg className="h-6 w-6 fill-current" viewBox="0 0 24 24">
<path d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>
</svg>
</button>
</div>
</div>
</nav>
);
};
export default TopNav;
// components/Footer.tsx
import React from 'react';
const Footer: React.FC = () => {
return (
<footer className="bg-gray-800 text-white">
<div className="container mx-auto px-6 py-8">
<div className="flex flex-col md:flex-row justify-between items-center">
<div className="mb-6 md:mb-0">
<span className="text-2xl font-bold">YourLogo</span>
</div>
<div className="flex mt-4 md:m-0 space-x-6">
<a href="#" className="hover:text-indigo-400 transition duration-300">
<span className="sr-only">Facebook</span>
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" clipRule="evenodd" />
</svg>
</a>
<a href="#" className="hover:text-indigo-400 transition duration-300">
<span className="sr-only">Twitter</span>
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M8.29 20.251c7.547 0 11.675-6.253 11.675-11.675 0-.178 0-.355-.012-.53A8.348 8.348 0 0022 5.92a8.19 8.19 0 01-2.357.646 4.118 4.118 0 001.804-2.27 8.224 8.224 0 01-2.605.996 4.107 4.107 0 00-6.993 3.743 11.65 11.65 0 01-8.457-4.287 4.106 4.106 0 001.27 5.477A4.072 4.072 0 012.8 9.713v.052a4.105 4.105 0 003.292 4.022 4.095 4.095 0 01-1.853.07 4.108 4.108 0 003.834 2.85A8.233 8.233 0 012 18.407a11.616 11.616 0 006.29 1.84" />
</svg>
</a>
<a href="#" className="hover:text-indigo-400 transition duration-300">
<span className="sr-only">GitHub</span>
<svg className="h-6 w-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path fillRule="evenodd" d="M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0112 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0022 12.017C22 6.484 17.522 2 12 2z" clipRule="evenodd" />
</svg>
</a>
</div>
</div>
<div className="mt-8 border-t border-gray-700 pt-8 md:flex md:items-center md:justify-between">
<div className="flex space-x-6 md:order-2">
<a href="#" className="text-gray-400 hover:text-indigo-400 transition duration-300">Privacy Policy</a>
<a href="#" className="text-gray-400 hover:text-indigo-400 transition duration-300">Terms of Service</a>
</div>
<p className="mt-8 text-base text-gray-400 md:mt-0 md:order-1">
© 2024 YourCompany. All rights reserved.
</p>
</div>
</div>
</footer>
);
};
export default Footer;
Then update your app/page.tsx to add your new imports, components, and a new div to wrap everything.
import React from 'react';
import TopNav from './TopNav';
import Footer from './Footer';
// ... other imports
const LandingPage: React.FC = () => {
return (
<div className="flex flex-col min-h-screen">
<TopNav />
<main className="font-sans">
{/* Other sections of your landing page */}
</main>
<Footer />
</div>
);
};
export default LandingPage;
Done!
Do a quick git add -A, then commit and push it and we’ll move on.
Database Setup & Lead Collection
With your basic lander done, we want to start thinking about what to do with it. The first think I think about doing is collecting leads - seeing if people are interested in signing up or learning more. We don’t have a lead collection for, but even if we did, where would we store them?
Let’s start by setting up our database.
MongoDB Atlas is my choice, its free and does a good job, you can scale with mongo from there easily.
Go to https://www.mongodb.com/cloud/atlas and sign up or log in
Create a new project and cluster (choose the free tier for development) lets get building the integration.
While it is provisioning our database, lets start building the integration for our app.
Add mongoose (A library for integrating with MongoDB) to your project with the following command in your terminal:
bun add mongoose
Then create a database connection file (dbConnect.ts) in a new folder called lib.
import mongoose from 'mongoose';
const MONGODB_URI = process.env.MONGODB_URI!;
if (!MONGODB_URI) {
throw new Error('Please define the MONGODB_URI environment variable inside .env.local');
}
type MongooseCache = {
conn: typeof mongoose | null;
promise: Promise<typeof mongoose> | null;
};
// Declare mongoose on the global object
declare global {
var mongoose: MongooseCache | undefined;
}
let cached = global.mongoose;
if (!cached) {
cached = global.mongoose = { conn: null, promise: null };
}
async function dbConnect(): Promise<typeof mongoose> {
if (!cached) {
cached = { conn: null, promise: null };
}
if (cached.conn) {
return cached.conn;
}
if (!cached.promise) {
const opts = {
bufferCommands: false,
};
cached.promise = mongoose.connect(MONGODB_URI, opts).then(() => {
return mongoose;
});
}
try {
cached.conn = await cached.promise;
} catch (e) {
cached.promise = null;
throw e;
}
return cached.conn;
}
export default dbConnect;
We also want to create a model for our leads, so in a new models folder create Lead.ts:
// models/Lead.ts
import mongoose, { Schema, Document } from 'mongoose';
export interface ILead extends Document {
name: string;
email: string;
createdAt: Date;
}
const LeadSchema: Schema = new Schema({
name: { type: String, required: true },
email: { type: String, required: true, unique: true },
createdAt: { type: Date, default: Date.now },
});
export default mongoose.models.Lead || mongoose.model<ILead>('Lead', LeadSchema);
By now our database should have finished being provisioned. You should have a dialogue box asking you to create a username and password for your database - choose something tough to guess and a strong password and create your database user, press choose connection method, click drivers, and copy the connection URI. If it still says provisioning, just wait, the dialogue box will update when its ready.
Now that you have your connection URI, create a file in the root directory of your project called .env.local and add this:
MONGODB_URI=<your-connection-uri>
Make sure to replace <your-connection-uri> with the one you copied from mongodb.
Head back to mongo and under network access in your project, add this ip: 0.0.0.0/0
Done - you now have a connected database and a model for leads. There is something missing though… no way for you to collect leads, let’s go back to make a new component, an API route and add the component to our landing page.
Create a couple of new folder at app/api/leads and in our new leads folder create route.ts:
import { NextResponse } from 'next/server';
import dbConnect from '@/lib/dbConnect';
import Lead from '@/models/Lead';
export async function POST(request: Request) {
try {
await dbConnect();
const body = await request.json();
const { name, email } = body;
const lead = await Lead.create({ name, email });
return NextResponse.json({ success: true, data: lead }, { status: 201 });
} catch (error) {
return NextResponse.json({ success: false, error: 'Error creating lead' }, { status: 400 });
}
}
Our new component at components/LeadForm.tsx:
'use client';
import { useState, FormEvent } from 'react';
interface LeadFormProps {
title?: string;
subtitle?: string;
buttonText?: string;
}
export default function LeadForm({
title = 'Stay Updated',
subtitle = 'Sign up for our newsletter to receive the latest updates and exclusive offers.',
buttonText = 'Subscribe',
}: LeadFormProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [message, setMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
setMessage('');
setIsLoading(true);
try {
const response = await fetch('/api/leads', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email }),
});
if (response.ok) {
setName('');
setEmail('');
setMessage('Thank you for subscribing!');
} else {
setMessage('An error occurred. Please try again.');
}
} catch (error) {
setMessage('An error occurred. Please try again.');
} finally {
setIsLoading(false);
}
};
return (
<section className="bg-gray-100 py-16">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white shadow-lg rounded-xl p-8">
<h2 className="text-3xl font-bold text-gray-800 mb-2">{title}</h2>
<p className="text-gray-600 mb-6">{subtitle}</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="name" className="block text-sm font-medium text-gray-700 mb-1">
Name
</label>
<input
type="text"
id="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="John Doe"
required
className="w-full text-black px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email
</label>
<input
type="email"
id="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="john@example.com"
required
className="w-full px-4 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<button
type="submit"
disabled={isLoading}
className={`w-full bg-indigo-600 text-white font-semibold py-3 px-4 rounded-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-colors duration-300 ${
isLoading ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
{isLoading ? 'Subscribing...' : buttonText}
</button>
</form>
{message && (
<p className={`mt-4 text-sm ${message.includes('error') ? 'text-red-600' : 'text-green-600'} font-medium`}>
{message}
</p>
)}
</div>
</div>
</section>
);
}
and add it to app/page.tsx like this:
import dynamic from 'next/dynamic';
// ... other imports
// Dynamically import LeadForm with SSR disabled
const LeadForm = dynamic(() => import('../components/LeadForm'), { ssr: false });
export default function Home() {
return (
{/* Other sections of your landing page */}
<LeadForm
title="Join Our Beta"
subtitle="Sign up now to get early access and exclusive offers for our new SaaS platform."
buttonText="Get Early Access"
/>
{/* Other sections of your landing page */}
);
}
Congratulations! You now have a live lead collection form that feeds directly into your database. (In a future post we’re going to talk about building an admin panel, but for now if you go into the mongoDB, look at collections, there will be a Leads table that you can see your sign ups.)
From here you should probably run the following commands again to push this to your git repo and vercel
git add -A
git commit -m "Database integration added, lead form live"
git push
App Interface
With our landing page out of the way - in a real project here is where you can start your market/demand testing. See if anyone signs up (you will need to make edits for the dead buttons, links etc, and make sure you’re happy with all the copy, deploy, add a domain, etc.). If you don’t see demand, don’t bother continuing unless you have more reasons to keep building.
If you’re happy with the response, let’s keep building.
Start by creating a new directory at app/dashboard. This is going to be our protected route where logged in users will be able to use our app.
In this new directory add a new page.tsx with the following code. We’re starting very minimal, get the functionality working then make it better.
// app/dashboard/page.tsx
import { Upload } from '@/components/Upload'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function DashboardPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">AI Headshot Generator</h1>
<Card>
<CardHeader>
<CardTitle>Upload Your Photo</CardTitle>
</CardHeader>
<CardContent>
<Upload />
<div className="mt-8">
<h3 className="text-lg font-semibold mb-2">Generated Headshot</h3>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[300px]">
<p className="text-muted-foreground">Your generated headshot will appear here</p>
{/* Placeholder for generated image */}
{/* <img src="/generated-headshot.jpg" alt="Generated Headshot" className="max-w-full max-h-full object-contain" /> */}
</div>
</div>
</CardContent>
</Card>
</div>
)
}
We also are importing a new component here, so lets create that as well:
// components/Upload.tsx
"use client";
import { useState } from 'react'
import { Upload as UploadIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
export function Upload() {
const [file, setFile] = useState<File | null>(null)
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setFile(e.target.files[0])
}
}
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
// Here you would typically handle the file upload to your backend
console.log('File to upload:', file)
}
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div className="grid w-full max-w-sm items-center gap-1.5">
<Label htmlFor="picture">Picture</Label>
<Input id="picture" type="file" onChange={handleFileChange} accept="image/png, image/jpeg" />
</div>
{file && (
<div className="text-sm text-muted-foreground">
Selected file: {file.name}
</div>
)}
<Button type="submit">
<UploadIcon className="mr-2 h-4 w-4" /> Generate Headshot
</Button>
</form>
)
}
Let’s talk about UI libraries for a second. Historically there has been a lot of use of prorietary component libraries like MUI. My preference is to use something more like Shadcn-ui, an open source library that you can build off of over time and in turn create your own component library.
I’m going to walk you through installation for what we’re doing, but I recommend going to their side and reading the docs yourself (Also important if you’re not using Bun).
If you are using bun and just want to follow along, enter these terminal commands (There will be some options, choose whatever you like, I went with defaults):
bunx --bun shadcn@latest init
bunx --bun shadcn@latest add button card input label
With that done, if you navigate to https://localhost:3000/dashboard you should see a simple upload form that we will be starting with.
Authentication
With a basic interface in place, we’re going to need some real functionality unfortunately all of the functionality we build is going to be connected to a unique user ID, so we need to integrate authentication first.
We’re going to use Next-Auth and Google Oauth.
Start by installing it with this command:
bun add next-auth
And these to your .env.local (We will update their values later)
GOOGLE_CLIENT_ID=your_google_client_id
GOOGLE_CLIENT_SECRET=your_google_client_secret
NEXTAUTH_SECRET=your_nextauth_secret
NEXTAUTH_URL=http://localhost:3000
We need a new directory and file, create it at app/api/auth/[...nextauth]/route.ts and add this code:
// app/api/auth/[...nextauth]/route.ts
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth";
const handler = NextAuth(authOptions);
export const GET = handler;
export const POST = handler;
and create a new file in lib called auth.ts:
// lib/auth.ts
import { AuthOptions, DefaultSession } from "next-auth";
import GoogleProvider from "next-auth/providers/google";
import dbConnect from "@/lib/dbConnect";
import User from "@/models/User";
declare module "next-auth" {
interface Session extends DefaultSession {
user?: {
id?: string;
} & DefaultSession["user"]
}
}
export const authOptions: AuthOptions = {
providers: [
GoogleProvider({
clientId: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
}),
],
callbacks: {
async signIn({ user, account }) {
if (account?.provider === "google") {
const { name, email, image } = user;
try {
await dbConnect();
let dbUser = await User.findOne({ email });
if (!dbUser) {
dbUser = new User({ name, email, image });
await dbUser.save();
}
// Ensure the user object has the id field set
user.id = dbUser.id;
return true;
} catch (error) {
console.error("Error saving user:", error);
return false;
}
}
return true;
},
async session({ session, token }) {
if (session.user) {
session.user.id = token.sub;
}
return session;
},
async jwt({ token, user }) {
if (user) {
token.sub = user.id;
}
return token;
},
},
pages: {
signIn: '/auth/signin',
},
session: {
strategy: "jwt",
},
};
Our database needs a User model, so add models/User.ts:
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema({
_id: {
type: String,
alias: 'id',
},
name: String,
email: {
type: String,
unique: true,
required: true,
},
image: String,
}, { timestamps: true });
// This will allow us to use 'id' instead of '_id' when working with the model
UserSchema.virtual('id').get(function() {
return this._id;
});
// Ensure virtual fields are serialized
UserSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: function (doc, ret) { delete ret._id; }
});
export default mongoose.models.User || mongoose.model('User', UserSchema);
And a components/SessionProvider.tsx:
// components/SessionProvider.tsx
'use client';
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export default function SessionProvider({ children, session }: any) {
return (
<NextAuthSessionProvider session={session}>
{children}
</NextAuthSessionProvider>
);
}
We also need to edit our app/layout.tsx - adding the Session Provider and may as well add our title and description while we’re here.
// app/layout.tsx
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { getServerSession } from "next-auth/next";
import SessionProvider from "@/components/SessionProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "AI Head Shots",
description: "Generate professional headshots in seconds",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await getServerSession();
return (
<html lang="en">
<body className={inter.className}>
<SessionProvider session={session}>
{children}
</SessionProvider>
</body>
</html>
);
}
Update our components/TopNav.tsx to add a login button
// components/TopNav.tsx
'use client';
import React from 'react';
import { useSession, signIn, signOut } from 'next-auth/react';
import Link from 'next/link';
const TopNav: React.FC = () => {
const { data: session } = useSession();
return (
<nav className="bg-white shadow-md">
<div className="container mx-auto px-6 py-3 flex justify-between items-center">
<div className="flex items-center">
<span className="text-xl font-bold text-gray-800">YourLogo</span>
</div>
<div className="hidden md:flex items-center space-x-8">
<Link href="/" className="text-gray-800 hover:text-indigo-600 transition duration-300">Home</Link>
<Link href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Features</Link>
<Link href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Pricing</Link>
<Link href="#" className="text-gray-800 hover:text-indigo-600 transition duration-300">Contact</Link>
{session ? (
<>
<Link href="/dashboard" className="text-gray-800 hover:text-indigo-600 transition duration-300">Dashboard</Link>
<button
onClick={() => signOut({ callbackUrl: '/' })}
className="bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded transition duration-300"
>
Sign Out
</button>
</>
) : (
<button
onClick={() => signIn('google', { callbackUrl: '/dashboard' })}
className="bg-indigo-500 hover:bg-indigo-600 text-white font-bold py-2 px-4 rounded transition duration-300"
>
Sign In
</button>
)}
</div>
<div className="md:hidden">
<button className="text-gray-800 hover:text-indigo-600 focus:outline-none">
<svg className="h-6 w-6 fill-current" viewBox="0 0 24 24">
<path d="M4 5h16a1 1 0 0 1 0 2H4a1 1 0 1 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2zm0 6h16a1 1 0 0 1 0 2H4a1 1 0 0 1 0-2z"/>
</svg>
</button>
</div>
</div>
</nav>
);
};
export default TopNav;
Our app/dashboard/layout.tsx will be updated to make sure only authenticated users can access the app:
// app/dashboard.layout.tsx
import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation";
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
if (!session) {
redirect("/api/auth/signin?callbackUrl=/dashboard");
}
return <>{children}</>;
}
From here, you need to get your google Oauth credentials, but this is a but more than I feel like typing out, so follow just the parts of this tutorial that are involved with setting up your google cloud project and getting your client & secret keys: https://medium.com/@nithishreddy0627/step-by-step-guide-building-a-next-js-63cd5b2bbbf3
Under "Authorized JavaScript origins", add: http://localhost:3000
and/or
https://your-production-domain.com (when you're ready for production)
Under "Authorized redirect URIs", add:
http://localhost:3000/api/auth/callback/google
and/or
https://your-production-domain.com/api/auth/callback/google (when you're ready for production)
update your .env.local with your new google credentials, as well as a nextauth secret (This can be any string, it is used as a seed to generate credentials, make it long).
You can generate a good nextauth_secret with the following terminal command (just copy and paste the output):
openssl rand -base64 32
Your next auth url can stay as https://localhost:3000 for now - this is correct for local development, when we deploy the project updates we will add your vercel or custom domain in its place.
Commit your changes to github (After you test locally) and lets move back to vercel. If you want the app to work in deployment the way it does locally, you’ll need to add the environmental variables in vercel as well.
Take a look at your vercel project and go to its settings. Start in domains copy the domain you have from vercel (Or your custom domain) and go back to your google project, edit your credentials and add the domain to Javascript origins and add your domain with the /api/auth/callback/google path to Authorized Redirect URIs.
Save and head back to vercel settings and go to Environmental Variables this time.
Copy the contents of your .env.local file and paste it into the key section of the create new form. It should automatically create all the different key value pairs you need. Make sure to change the nextauth_url to your new domain.
Save, redeploy your app (You need to redeploy or it won’t work), and do some testing, you should now be able to log in with google on your app.
Image Upload
Now that we have auth implemented, lets start making this thing function.
To do that we will add an image upload feature using UploadThing. This is my favorite file upload api/service, really is a game changer. The free tier will be more than enough for what we are doing - if you go live and get customers, you can increase your membership level when the time comes.
Follow along with what I am doing or you can see docs here: https://docs.uploadthing.com/
Install the package:
bun add uploadthing @uploadthing/react
Create a new project in upload thing, go to API Keys, and you can just copy paste the environmental variables straight to your .env.local and vercel project. It should look something like this:
UPLOADTHING_SECRET='Your_Secret_Key'
UPLOADTHING_APP_ID='Your_ID'
Create your uploadthing route at app/api/uploadthing/core.ts:
import { createUploadthing, type FileRouter } from "uploadthing/next";
import { UploadThingError } from "uploadthing/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth";
const f = createUploadthing();
const auth = async (req: Request) => {
const session = await getServerSession(authOptions);
return session?.user;
};
// FileRouter for your app, can contain multiple FileRoutes
export const ourFileRouter = {
// Define as many FileRoutes as you like, each with a unique routeSlug
imageUploader: f({ image: { maxFileSize: "4MB" } })
// Set permissions and file types for this FileRoute
.middleware(async ({ req }) => {
// This code runs on your server before upload
const user = await auth(req);
// If you throw, the user will not be able to upload
if (!user) throw new UploadThingError("Unauthorized");
// Whatever is returned here is accessible in onUploadComplete as `metadata`
return { userId: user.id };
})
.onUploadComplete(async ({ metadata, file }) => {
// This code RUNS ON YOUR SERVER after upload
console.log("Upload complete for userId:", metadata.userId);
console.log("file url", file.url);
// !!! Whatever is returned here is sent to the clientside `onClientUploadComplete` callback
return { uploadedBy: metadata.userId };
}),
} satisfies FileRouter;
export type OurFileRouter = typeof ourFileRouter;
In the same directory make your route.ts:
// app/api/uploadthing/route.ts
import { createRouteHandler } from "uploadthing/next";
import { ourFileRouter } from "./core";
// Export routes for Next App Router
export const { GET, POST } = createRouteHandler({
router: ourFileRouter,
// Apply an (optional) custom config:
// config: { ... },
});
Update your tailwind.config.ts (This is in your projects root directory):
// tailwind.config.ts
import { withUt } from "uploadthing/tw";
export default withUt({
// Your existing Tailwind config
content: ["./src/**/*.{ts,tsx,mdx}"],
...
});
We also want to take the recommended step of creating the UploadThing components. Add a new folder in the root directory called utils and add this file:
// utils/uploadthing.ts
import {
generateUploadButton,
generateUploadDropzone,
} from "@uploadthing/react";
import type { OurFileRouter } from "@/app/api/uploadthing/core";
export const UploadButton = generateUploadButton<OurFileRouter>();
export const UploadDropzone = generateUploadDropzone<OurFileRouter>();
Now we need to update our Upload.tsx component and Dashboard:
// app/dashboard/page.tsx
"use client";
import { useState } from 'react';
import { Upload } from '@/components/Upload'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export default function DashboardPage() {
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const handleGenerateHeadshot = (url: string) => {
setGeneratedImageUrl(url);
};
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">AI Headshot Generator</h1>
<Card>
<CardHeader>
<CardTitle>Upload Your Photo & Generate Headshot</CardTitle>
</CardHeader>
<CardContent>
<Upload onUploadComplete={handleGenerateHeadshot} />
<div className="mt-8">
<h3 className="text-lg font-semibold mb-2">Generated Headshot</h3>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[300px]">
{generatedImageUrl ? (
<img src={generatedImageUrl} alt="Generated Headshot" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground">Your generated headshot will appear here</p>
)}
</div>
</div>
</CardContent>
</Card>
</div>
)
}
// components/Upload.tsx
"use client";
import { useState } from 'react'
import { Upload as UploadIcon } from 'lucide-react'
import { UploadButton } from "@/utils/uploadthing";
export function Upload({ onUploadComplete }: { onUploadComplete: (url: string) => void }) {
const [isUploading, setIsUploading] = useState(false);
const handleUploadComplete = async (res: { url: string }[]) => {
if (res && res[0]) {
setIsUploading(true);
const uploadedUrl = res[0].url;
console.log("File uploaded:", uploadedUrl);
// Here you would call your AI service to generate the headshot
// For now, we'll just use the uploaded image
try {
// Simulating AI processing time
await new Promise(resolve => setTimeout(resolve, 2000));
// In a real scenario, you'd replace this with your AI service call
const generatedHeadshotUrl = uploadedUrl;
onUploadComplete(generatedHeadshotUrl);
} catch (error) {
console.error("Error generating headshot:", error);
alert("Failed to generate headshot. Please try again.");
} finally {
setIsUploading(false);
}
}
};
return (
<div className="space-y-4">
<UploadButton
endpoint="imageUploader"
onClientUploadComplete={handleUploadComplete}
onUploadError={(error: Error) => {
alert(`ERROR! ${error.message}`);
}}
onUploadBegin={() => {
setIsUploading(true);
}}
>
{isUploading ? (
<div className="flex items-center space-x-2">
<span className="animate-spin">🔄</span>
<span>{isUploading ? "Processing..." : "Uploading..."}</span>
</div>
) : (
<div className="flex items-center space-x-2">
<UploadIcon className="h-4 w-4" />
<span>Upload & Generate Headshot</span>
</div>
)}
</UploadButton>
</div>
)
}
Now when we select a file, it is uploaded to our upload thing account and displayed on the dashboard.
We will add a past generations feature later, letting us see what we uploaded and what the result want.
AI Image Generation
Landing page Check
Interface Check
Image Upload Check
Now we need the app to actually do what our entire purpose is - generate AI headshots. For this we are going to use a model hosted on https://replicate.com/
In this project I’m going to be using https://replicate.com/tencentarc/photomaker but using this framework you can use any pre-existing model on replicate, or train your own.
So go create an account at replicate, add your API key from it to your .env.local as REPLICATE_API and lets start building.
a typical call to the Replcate API to use this model would look like this (Straight from their docs):
import Replicate from "replicate";
const replicate = new Replicate();
const input = {
prompt: "A photo of a scientist img receiving the Nobel Prize",
num_steps: 50,
input_image: "https://replicate.delivery/pbxt/KFkSv1oX0v3e7GnOrmzULGqCA8222pC6FI2EKcfuCZWxvHN3/newton_0.jpg"
};
const output = await replicate.run("tencentarc/photomaker:ddfc2b08d209f9fa8c1eca692712918bd449f695dabb4a958da31802a9570fe4", { input });
console.log(output)
//=> ["https://replicate.delivery/pbxt/WPelbpCPIDTmIiye4CGXbQP...
So how do we integrate this with our app? And how do we generate a professional headshot instead of a Nobel Prize winning scientist?
This is how:
Install the Replicate package:
bun add replicate
First we want to add an api route for this. We will do a couple of things here. First at its basic level it will send our link returned from uploadthing to the replicate api and generate an image based on the prompt we send then send a image back. On top of that we’re going to give an option to have the image male or female (This is to avoid hallucinations that change the gender from what is needed).
// app/api/replicate/route.ts
import { NextResponse } from 'next/server';
import Replicate from 'replicate';
export async function POST(req: Request) {
try {
const { uploadedImageUrl, gender } = await req.json();
console.log('Received uploadedImageUrl:', uploadedImageUrl);
console.log('Received gender:', gender);
const replicate = new Replicate({
auth: process.env.REPLICATE_API_TOKEN,
});
const prompt = gender === 'male'
? "A professional business headshot photo of a man 4k img"
: "A professional headshot photo of a woman 4k img";
const input = {
prompt,
num_steps: 50,
input_image: uploadedImageUrl
};
console.log('Starting prediction with Replicate');
const prediction = await replicate.predictions.create({
version: "ddfc2b08d209f9fa8c1eca692712918bd449f695dabb4a958da31802a9570fe4",
input: input,
});
return NextResponse.json({ predictionId: prediction.id });
} catch (error) {
console.error('Detailed error:', error);
let errorMessage = 'An unknown error occurred';
if (error instanceof Error) {
errorMessage = error.message;
}
return NextResponse.json(
{ error: 'Failed to start image generation', details: errorMessage },
{ status: 500 }
);
}
}
We also need a app/api/replicate/status/route.ts - this is because we’re deploying to vercel and there is a 10 second timeout on api calls. Instead we’re implementing polling to get around the timeout.
// app/api/replicate/status/route.ts
import { NextResponse } from 'next/server';
import Replicate from 'replicate';
import { getServerSession } from "next-auth/next";
import dbConnect from '@/lib/dbConnect';
import Generation from '@/models/Generation';
import { authOptions } from "@/lib/auth";
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const predictionId = searchParams.get('id');
const originalImageUrl = searchParams.get('originalImageUrl');
const gender = searchParams.get('gender');
if (!predictionId || !originalImageUrl || !gender) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const session = await getServerSession(authOptions);
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const replicate = new Replicate({
auth: process.env.REPLICATE_API_TOKEN,
});
const prediction = await replicate.predictions.get(predictionId);
if (prediction.status === 'succeeded') {
const generatedImageUrl = prediction.output[0];
// Save to MongoDB
await dbConnect();
await Generation.create({
userId: session.user.id,
originalImageUrl,
generatedImageUrl,
gender,
});
return NextResponse.json({ status: 'complete', output: generatedImageUrl });
} else if (prediction.status === 'failed') {
return NextResponse.json({ status: 'failed', error: prediction.error });
} else {
return NextResponse.json({ status: 'processing' });
}
} catch (error) {
console.error('Error checking prediction status:', error);
return NextResponse.json({ error: 'Failed to check prediction status' }, { status: 500 });
}
}
We’ll install a new Shadcd/ui component using this command:
bunx shadcn@latest add select
With our new select ui installed, lets edit the Upload component.
// components/Upload.tsx
"use client";
import { useState, useEffect } from 'react'
import { Upload as UploadIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { UploadDropzone } from "@/utils/uploadthing";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function Upload({ onUploadComplete, onGenderChange }: {
onUploadComplete: (url: string, gender: string) => void,
onGenderChange: (gender: string) => void
}) {
const [uploadedFileUrl, setUploadedFileUrl] = useState<string | null>(null);
const [selectedGender, setSelectedGender] = useState<string>("male");
const handleUploadComplete = (res: { url: string }[]) => {
if (res && res[0]) {
const url = res[0].url;
setUploadedFileUrl(url);
onUploadComplete(url, selectedGender);
}
};
const handleGenderChange = (gender: string) => {
setSelectedGender(gender);
onGenderChange(gender);
};
return (
<div className="space-y-4">
<Select onValueChange={handleGenderChange} defaultValue="male">
<SelectTrigger className="w-[180px]">
<SelectValue placeholder="Select gender" />
</SelectTrigger>
<SelectContent>
<SelectItem value="male">Male</SelectItem>
<SelectItem value="female">Female</SelectItem>
</SelectContent>
</Select>
<UploadDropzone
endpoint="imageUploader"
onClientUploadComplete={handleUploadComplete}
onUploadError={(error: Error) => {
alert(`Upload ERROR! ${error.message}`);
}}
/>
{uploadedFileUrl && (
<div className="text-sm text-muted-foreground">
File uploaded successfully. URL: {uploadedFileUrl}
</div>
)}
</div>
)
}
And update our dashboard page to ensure everything works together.
// app/dashboard/page.
"use client";
import { useState, useEffect } from 'react';
import { Upload } from '@/components/Upload'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
export default function DashboardPage() {
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [selectedGender, setSelectedGender] = useState<string>("male");
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [predictionId, setPredictionId] = useState<string | null>(null);
const handleUploadComplete = (url: string, gender: string) => {
setUploadedImageUrl(url);
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
};
const handleGenderChange = (gender: string) => {
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
};
const handleGenerateHeadshot = async () => {
if (!uploadedImageUrl) {
alert("Please upload an image first.");
return;
}
setIsGenerating(true);
try {
const response = await fetch('/api/replicate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ uploadedImageUrl, gender: selectedGender }),
});
if (!response.ok) {
throw new Error('Failed to start image generation');
}
const data = await response.json();
setPredictionId(data.predictionId);
} catch (error) {
console.error("Error starting headshot generation:", error);
alert("Failed to start headshot generation. Please try again.");
setIsGenerating(false);
}
};
useEffect(() => {
if (!predictionId) return;
const checkStatus = async () => {
try {
const response = await fetch(`/api/replicate/status?id=${predictionId}`);
if (!response.ok) {
throw new Error('Failed to check prediction status');
}
const data = await response.json();
if (data.status === 'complete') {
setGeneratedImageUrl(data.output[0]);
setIsGenerating(false);
setPredictionId(null);
} else if (data.status === 'failed') {
throw new Error(data.error || 'Image generation failed');
} else {
// Still processing, check again in 2 seconds
setTimeout(checkStatus, 2000);
}
} catch (error) {
console.error("Error checking prediction status:", error);
alert("Failed to generate headshot. Please try again.");
setIsGenerating(false);
setPredictionId(null);
}
};
checkStatus();
}, [predictionId]);
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-6">AI Headshot Generator</h1>
<Card>
<CardHeader>
<CardTitle>Upload Your Photo</CardTitle>
</CardHeader>
<CardContent>
<Upload onUploadComplete={handleUploadComplete} onGenderChange={handleGenderChange} />
{uploadedImageUrl && (
<Button
onClick={handleGenerateHeadshot}
disabled={isGenerating}
className="mt-4"
>
{isGenerating ? "Generating..." : "Generate Headshot"}
</Button>
)}
<div className="mt-8">
<h3 className="text-lg font-semibold mb-4">Before and After</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<h4 className="text-md font-medium mb-2">Original Image</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[300px]">
{uploadedImageUrl ? (
<img src={uploadedImageUrl} alt="Original Image" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground">Upload an image to see it here</p>
)}
</div>
</div>
<div>
<h4 className="text-md font-medium mb-2">Generated Headshot</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[300px]">
{isGenerating ? (
<p>Generating headshot...</p>
) : generatedImageUrl ? (
<img src={generatedImageUrl} alt="Generated Headshot" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground">Your generated headshot will appear here</p>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
With that done, everything should be working, you’re now able to generate head shots with your image.
You can improve the prompts or look at the model and change the parameters to improve, but this works so I’m leaving it here and moving on.
Do some testing, add your new environmental variables to vercel, and commit to github. You have a fully functional web app. A few problems remain though… we need a top nav in the app not just the landing page, we need a generation history page, and the biggest, we need a way to make money, lets dive into navigation and past jobs, then we’ll finish strong by adding payments as our last step.
Layout & Past Generations
Let’s start with the easy part, we juts want a quick nav bar that allows your users to see where they are, logout, etc.
First install a new Shadcn ui element:
bunx shadcn@latest add dropdown-menu
Create a new component for the user menu:
// components/UserButton.tsx
"use client";
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function UserButton() {
const { data: session } = useSession();
const [isOpen, setIsOpen] = useState(false);
if (!session) return null;
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2">
{session.user?.name}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuItem className="font-normal">
{session.user?.name}
</DropdownMenuItem>
<DropdownMenuItem>
Billing
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => signOut()}>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
and lastly update our app/dashboard/layout.tsx:
import { getServerSession } from "next-auth/next";
import { redirect } from "next/navigation";
import Link from 'next/link';
import { UserButton } from '@/components/UserButton';
export default async function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const session = await getServerSession();
if (!session) {
redirect("/auth/signin?callbackUrl=/dashboard");
}
return (
<div className="min-h-screen bg-background flex flex-col">
<header className="sticky top-0 z-40 w-full border-b bg-background">
<div className="container mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex flex-col sm:flex-row items-center justify-between py-4 space-y-2 sm:space-y-0">
<div className="flex items-center space-x-2 sm:space-x-4">
<Link href="/dashboard" className="font-bold text-xl sm:text-2xl">
AI Headshots
</Link>
<nav className="flex items-center space-x-2 sm:space-x-4">
<Link
href="/dashboard"
className="text-sm font-medium transition-colors hover:text-primary"
>
Home
</Link>
<Link
href="/dashboard/past-generations"
className="text-sm font-medium transition-colors hover:text-primary"
>
Past Generations
</Link>
</nav>
</div>
<UserButton />
</div>
</div>
</header>
<main className="flex-grow container mx-auto px-4 sm:px-6 lg:px-8 py-6">
{children}
</main>
</div>
);
}
With that out of the way, and a link for users to get to past generations, lets actually build that feature.
First we need a few new files.
Start with a app/api/generations/route.ts
// app/api/generations/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from "next-auth/next";
import dbConnect from '@/lib/dbConnect';
import Generation from '@/models/Generation';
import { authOptions } from '@/lib/auth';
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
await dbConnect();
const generations = await Generation.find({ userId: session.user.id }).sort({ createdAt: -1 });
return NextResponse.json(generations);
} catch (error) {
console.error('Error fetching generations:', error);
return NextResponse.json({ error: 'Failed to fetch generations' }, { status: 500 });
}
}
and the generations page:
// app/dashboard/past-generations/page.tsx
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import ImageModal from '@/components/ImageModal';
interface Generation {
_id: string;
originalImageUrl: string;
generatedImageUrl: string;
gender: string;
createdAt: string;
}
export default function PastGenerationsPage() {
const [generations, setGenerations] = useState<Generation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedImage, setSelectedImage] = useState<{ url: string; alt: string } | null>(null);
useEffect(() => {
fetchGenerations();
}, []);
const fetchGenerations = async () => {
setIsLoading(true);
setError(null);
try {
const response = await fetch('/api/generations');
if (!response.ok) {
throw new Error('Failed to fetch generations');
}
const data = await response.json();
setGenerations(data);
} catch (error) {
console.error('Error fetching generations:', error);
setError('Failed to load past generations. Please try again.');
} finally {
setIsLoading(false);
}
};
const handleImageClick = (url: string, alt: string) => {
setSelectedImage({ url, alt });
};
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">Past Generations</h1>
{generations.length === 0 ? (
<p>No past generations found.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{generations.map((generation) => (
<Card key={generation._id}>
<CardHeader>
<CardTitle>Generation ({generation.gender})</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<h4 className="text-sm font-medium mb-2">Original Image</h4>
<img
src={generation.originalImageUrl}
alt="Original"
className="w-full h-40 object-cover rounded cursor-pointer"
onClick={() => handleImageClick(generation.originalImageUrl, "Original Image")}
/>
</div>
<div>
<h4 className="text-sm font-medium mb-2">Generated Headshot</h4>
<img
src={generation.generatedImageUrl}
alt="Generated"
className="w-full h-40 object-cover rounded cursor-pointer"
onClick={() => handleImageClick(generation.generatedImageUrl, "Generated Headshot")}
/>
</div>
<p className="text-xs text-muted-foreground">
Created at: {new Date(generation.createdAt).toLocaleString()}
</p>
</CardContent>
</Card>
))}
</div>
)}
{selectedImage && (
<ImageModal
imageUrl={selectedImage.url}
altText={selectedImage.alt}
onClose={() => setSelectedImage(null)}
/>
)}
</div>
);
}
We need a new component for when you click and image:
// components/ImageModal.tsx
import React, { useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
interface ImageModalProps {
imageUrl: string;
altText: string;
onClose: () => void;
}
const ImageModal: React.FC<ImageModalProps> = ({ imageUrl, altText, onClose }) => {
const modalRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
const handleDownload = () => {
const link = document.createElement('a');
link.href = imageUrl;
link.download = `${altText}.jpg`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div ref={modalRef} className="bg-white p-4 rounded-lg max-w-3xl max-h-[90vh] overflow-auto">
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold">{altText}</h2>
<Button variant="ghost" onClick={onClose}>
<X className="h-6 w-6" />
</Button>
</div>
<img src={imageUrl} alt={altText} className="max-w-full max-h-[70vh] object-contain mb-4" />
<Button onClick={handleDownload}>Download Image</Button>
</div>
</div>
);
};
export default ImageModal;
and a new model for the generations:
// models/Generation.ts
import mongoose from 'mongoose';
const GenerationSchema = new mongoose.Schema({
userId: { type: String, required: true },
originalImageUrl: { type: String, required: true },
generatedImageUrl: { type: String, required: true },
gender: { type: String, required: true },
createdAt: { type: Date, default: Date.now },
});
export default mongoose.models.Generation || mongoose.model('Generation', GenerationSchema);
With those out of the way, there is also some updates to make. Our current setup is not saving image links to the database, so we’ve made the generations model, but we need to tell the app to use that model when generating code.
A few updates:
// app/api/replicate/status/route.ts
import { NextResponse } from 'next/server';
import Replicate from 'replicate';
import { getServerSession } from "next-auth/next";
import dbConnect from '@/lib/dbConnect';
import Generation from '@/models/Generation';
import { authOptions } from '../../auth/[...nextauth]/route';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const predictionId = searchParams.get('id');
const originalImageUrl = searchParams.get('originalImageUrl');
const gender = searchParams.get('gender');
if (!predictionId || !originalImageUrl || !gender) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const session = await getServerSession(authOptions);
if (!session || !session.user || !session.user.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const replicate = new Replicate({
auth: process.env.REPLICATE_API_TOKEN,
});
const prediction = await replicate.predictions.get(predictionId);
if (prediction.status === 'succeeded') {
const generatedImageUrl = prediction.output[0];
// Save to MongoDB
await dbConnect();
await Generation.create({
userId: session.user.id,
originalImageUrl,
generatedImageUrl,
gender,
});
return NextResponse.json({ status: 'complete', output: generatedImageUrl });
} else if (prediction.status === 'failed') {
return NextResponse.json({ status: 'failed', error: prediction.error });
} else {
return NextResponse.json({ status: 'processing' });
}
} catch (error) {
console.error('Error checking prediction status:', error);
return NextResponse.json({ error: 'Failed to check prediction status' }, { status: 500 });
}
}
And update our main dashboard page.tsx:
// app/dashboard/page.
"use client";
import { useState, useEffect } from 'react';
import { Upload } from '@/components/Upload'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
export default function DashboardPage() {
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [selectedGender, setSelectedGender] = useState<string>("male");
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [predictionId, setPredictionId] = useState<string | null>(null);
const handleUploadComplete = (url: string, gender: string) => {
setUploadedImageUrl(url);
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
};
const handleGenderChange = (gender: string) => {
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
};
const handleGenerateHeadshot = async () => {
if (!uploadedImageUrl) {
alert("Please upload an image first.");
return;
}
setIsGenerating(true);
try {
const response = await fetch('/api/replicate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ uploadedImageUrl, gender: selectedGender }),
});
if (!response.ok) {
throw new Error('Failed to start image generation');
}
const data = await response.json();
setPredictionId(data.predictionId);
} catch (error) {
console.error("Error starting headshot generation:", error);
alert("Failed to start headshot generation. Please try again.");
setIsGenerating(false);
}
};
useEffect(() => {
if (!predictionId || !uploadedImageUrl) return;
const checkStatus = async () => {
try {
const response = await fetch(`/api/replicate/status?id=${predictionId}&originalImageUrl=${encodeURIComponent(uploadedImageUrl)}&gender=${selectedGender}`);
if (!response.ok) {
throw new Error('Failed to check prediction status');
}
const data = await response.json();
if (data.status === 'complete') {
setGeneratedImageUrl(data.output);
setIsGenerating(false);
setPredictionId(null);
} else if (data.status === 'failed') {
throw new Error(data.error || 'Image generation failed');
} else {
// Still processing, check again in 2 seconds
setTimeout(checkStatus, 2000);
}
} catch (error) {
console.error("Error checking prediction status:", error);
alert("Failed to generate headshot. Please try again.");
setIsGenerating(false);
setPredictionId(null);
}
};
checkStatus();
}, [predictionId, uploadedImageUrl, selectedGender]);
return (
<div className="space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">AI Headshot Generator</h1>
<Card>
<CardHeader>
<CardTitle>Upload Your Photo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Upload onUploadComplete={handleUploadComplete} onGenderChange={handleGenderChange} />
{uploadedImageUrl && (
<Button
onClick={handleGenerateHeadshot}
disabled={isGenerating}
className="w-full sm:w-auto"
>
{isGenerating ? "Generating..." : "Generate Headshot"}
</Button>
)}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Before and After</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="text-md font-medium">Original Image</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[200px] sm:h-[300px]">
{uploadedImageUrl ? (
<img src={uploadedImageUrl} alt="Original Image" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground text-center">Upload an image to see it here</p>
)}
</div>
</div>
<div className="space-y-2">
<h4 className="text-md font-medium">Generated Headshot</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[200px] sm:h-[300px]">
{isGenerating ? (
<p className="text-center">Generating headshot...</p>
) : generatedImageUrl ? (
<img src={generatedImageUrl} alt="Generated Headshot" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground text-center">Your generated headshot will appear here</p>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
Taking Payments
You now have, a fully functioning web app but no way to make money, and let’s be real, you’re not here for charity.
We need to integrate payments. For this project you will use stripe to have subscriptions, there will be 2 plans basic with a limited number of generations and pro with a lot more generations.
Go to stripe, join, set up an account and go to test mode (We dont want to be developing with live payments), there should be a toggle switch in the settings on the top right. Then go to developers so we can grab the publishable key and the secret key. We will replace these with the live keys in production. (Webhook secret will be addressed later, for now keep it as the placeholder that is already there).
STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_secret
Add the required packages:
bun add stripe @stripe/stripe-js @stripe/react-stripe-js
We’ll need a new api route for checkout:
// app/api/create-checkout-session/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import { stripe } from '@/lib/stripe';
import dbConnect from '@/lib/dbConnect';
import User from '@/models/User';
async function createCheckoutSession(priceId: string, userId: string, userEmail: string) {
const checkoutSession = await stripe.checkout.sessions.create({
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: `${process.env.NEXTAUTH_URL}/api/stripe/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.NEXTAUTH_URL}/dashboard`,
customer_email: userEmail,
client_reference_id: userId,
metadata: {
userId: userId,
},
});
return checkoutSession;
}
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
await dbConnect();
const user = await User.findOne({ email: session.user.email });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
const { priceId } = await req.json();
try {
const checkoutSession = await createCheckoutSession(priceId, user.id, user.email);
return NextResponse.json({ sessionId: checkoutSession.id });
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.json({ error: 'Failed to create checkout session' }, { status: 500 });
}
}
export async function GET(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.redirect('/api/auth/signin');
}
await dbConnect();
const user = await User.findOne({ email: session.user.email });
if (!user) {
return NextResponse.redirect('/dashboard?error=user_not_found');
}
const { searchParams } = new URL(req.url);
const priceId = searchParams.get('priceId');
if (!priceId) {
return NextResponse.redirect('/dashboard?error=missing_price_id');
}
try {
const checkoutSession = await createCheckoutSession(priceId, user.id, user.email);
return NextResponse.redirect(checkoutSession.url!);
} catch (error) {
console.error('Error creating checkout session:', error);
return NextResponse.redirect('/dashboard?error=checkout_creation_failed');
}
}
And a webhook hander (More about this later)
// app/api/webhooks/stripe/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import dbConnect from '@/lib/dbConnect';
import User from '@/models/User';
const relevantEvents = new Set([
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
]);
export async function POST(req: Request) {
const body = await req.text();
const sig = req.headers.get('stripe-signature') as string;
let event;
try {
event = stripe.webhooks.constructEvent(body, sig, process.env.STRIPE_WEBHOOK_SECRET!);
} catch (err: any) {
console.error('Error verifying webhook signature:', err.message);
return NextResponse.json({ error: `Webhook Error: ${err.message}` }, { status: 400 });
}
if (relevantEvents.has(event.type)) {
try {
await dbConnect();
switch (event.type) {
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionChange(event);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeletion(event);
break;
}
} catch (error) {
console.error('Error processing webhook:', error);
return NextResponse.json({ error: 'Webhook handler failed' }, { status: 500 });
}
}
return NextResponse.json({ received: true });
}
async function handleSubscriptionChange(event: any) {
const subscription = event.data.object;
const customerId = subscription.customer;
console.log('Processing subscription change for customer:', customerId);
const user = await User.findOne({ stripeCustomerId: customerId });
if (!user) {
console.error('User not found for customer ID:', customerId);
throw new Error('User not found');
}
const priceId = subscription.items.data[0].price.id;
const isActive = subscription.status === 'active';
console.log('Updating user:', user.id, 'with plan:', priceId, 'status:', isActive);
// Assign tokens based on the plan
let tokensToAssign = 0;
if (priceId === process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID) {
tokensToAssign = Number(process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_HEADSHOTS) || 5;
} else if (priceId === process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_ID) {
tokensToAssign = Number(process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_HEADSHOTS) || 15;
}
user.isSubscriptionActive = isActive;
user.stripePlanId = priceId;
user.tokens = isActive ? tokensToAssign : 0;
await user.save();
console.log('User updated successfully');
}
async function handleSubscriptionDeletion(event: any) {
const subscription = event.data.object;
const customerId = subscription.customer;
console.log('Processing subscription deletion for customer:', customerId);
const user = await User.findOne({ stripeCustomerId: customerId });
if (!user) {
console.error('User not found for customer ID:', customerId);
throw new Error('User not found');
}
user.isSubscriptionActive = false;
user.stripePlanId = null;
user.tokens = 0;
await user.save();
console.log('User subscription deleted successfully');
}
We should also update our User.ts model with some stripe variables:
// models/User.ts
import mongoose from 'mongoose';
const UserSchema = new mongoose.Schema({
name: String,
email: {
type: String,
unique: true,
required: true,
},
image: String,
stripeSubscriptionId: String,
stripePlanId: String,
isSubscriptionActive: {
type: Boolean,
default: false,
},
tokens: {
type: Number,
default: 0,
},
}, { timestamps: true });
// Virtual for `id`
UserSchema.virtual('id').get(function() {
return this._id.toString();
});
// Ensure virtual fields are serialized
UserSchema.set('toJSON', {
virtuals: true,
versionKey: false,
transform: function (doc, ret) { delete ret._id; }
});
export default mongoose.models.User || mongoose.model('User', UserSchema);
Because we’re going to give the user a limited number of generations based on their plan, lets implement some logic for counting tokens:
// app/api/use-token/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import dbConnect from '@/lib/dbConnect';
import User from '@/models/User';
export async function POST(req: Request) {
const session = await getServerSession(authOptions);
if (!session?.user) {
return NextResponse.json({ error: 'Not authenticated' }, { status: 401 });
}
await dbConnect();
const user = await User.findOne({ email: session.user.email });
if (!user) {
return NextResponse.json({ error: 'User not found' }, { status: 404 });
}
if (!user.isSubscriptionActive) {
return NextResponse.json({ error: 'No active subscription' }, { status: 403 });
}
if (user.tokens <= 0) {
return NextResponse.json({ error: 'No tokens available' }, { status: 403 });
}
user.tokens -= 1;
await user.save();
return NextResponse.json({ tokens: user.tokens });
}
Create a stripe Subscribe button component:
// components/SubscribeButton.tsx
import React from 'react';
import { useSession } from 'next-auth/react';
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY!);
export default function SubscribeButton({ priceId }: { priceId: string }) {
const { data: session } = useSession();
const handleSubscribe = async () => {
if (!session) {
alert('Please sign in to subscribe');
return;
}
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ price: priceId }),
});
const { sessionId } = await response.json();
const stripe = await stripePromise;
const { error } = await stripe!.redirectToCheckout({ sessionId });
if (error) {
console.error('Error:', error);
}
};
return (
<button onClick={handleSubscribe} className="bg-blue-500 text-white px-4 py-2 rounded">
Subscribe
</button>
);
}
I like a separate pricing page to redirect people to at different stages:
// app/pricing/page.tsx
"use client";
import { useState, useEffect } from 'react';
import { Card, CardHeader, CardContent, CardFooter } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check } from "lucide-react";
import { useSession } from "next-auth/react";
import { loadStripe, Stripe } from '@stripe/stripe-js';
const plans = [
{
name: "Standard",
price: `$${process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_PRICE || '10'}`,
features: [
`${process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_HEADSHOTS || '5'} AI-generated headshots per month`,
"Basic editing tools",
"Email support",
"720p resolution images"
],
priceId: process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID
},
{
name: "Pro",
price: `$${process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE || '20'}`,
features: [
`${process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_HEADSHOTS || '15'} AI-generated headshots per month`,
"Advanced editing tools",
"Priority email support",
"1080p resolution images",
"Custom backgrounds"
],
priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_ID
}
];
export default function PricingPage() {
const { data: session } = useSession();
const [isLoading, setIsLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | null>(null);
useEffect(() => {
console.log('Stripe Key:', process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY);
console.log('Standard Plan ID:', process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID);
console.log('Pro Plan ID:', process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_ID);
if (process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY) {
setStripePromise(loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY));
} else {
console.error('Stripe publishable key is missing');
setError("Stripe configuration is missing. Please check your environment variables.");
}
}, []);
const handleSubscribe = async (priceId: string) => {
if (!session) {
setError("Please sign in to subscribe");
return;
}
if (!stripePromise) {
setError("Stripe is not properly configured");
return;
}
setIsLoading(priceId);
setError(null);
try {
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to create checkout session');
}
const { sessionId } = await response.json();
const stripe = await stripePromise;
if (!stripe) {
throw new Error('Failed to load Stripe');
}
const { error } = await stripe.redirectToCheckout({ sessionId });
if (error) {
throw error;
}
} catch (error) {
console.error('Error:', error);
setError(error instanceof Error ? error.message : 'An unknown error occurred');
}
setIsLoading(null);
};
if (error) {
return (
<div className="container mx-auto py-10">
<h1 className="text-3xl font-bold text-center mb-10">Error</h1>
<p className="text-center text-red-500">{error}</p>
</div>
);
}
return (
<div className="container mx-auto py-10">
<h1 className="text-3xl font-bold text-center mb-10">Choose Your Plan</h1>
<div className="grid md:grid-cols-2 gap-8">
{plans.map((plan) => (
<Card key={plan.name} className="flex flex-col">
<CardHeader>
<h2 className="text-2xl font-bold">{plan.name}</h2>
<p className="text-4xl font-bold">{plan.price}<span className="text-base font-normal">/month</span></p>
</CardHeader>
<CardContent className="flex-grow">
<ul className="space-y-2">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleSubscribe(plan.priceId!)}
disabled={isLoading === plan.priceId || !plan.priceId || !stripePromise}
>
{isLoading === plan.priceId ? "Processing..." : `Subscribe to ${plan.name}`}
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
}
And it’s time to update our dashboard/page.tsx again. This time we’ll add a number of features pushing the user to purchase if they haven’t already, as well as letting them know how many tokens they currently have:
// app/dashboard/page.tsx
"use client";
import { useState, useEffect } from 'react';
import { useSearchParams } from 'next/navigation';
import { useSession } from 'next-auth/react';
import { Upload } from '@/components/Upload';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import Link from 'next/link';
export default function DashboardPage() {
const { data: session } = useSession();
const [uploadedImageUrl, setUploadedImageUrl] = useState<string | null>(null);
const [selectedGender, setSelectedGender] = useState<string>("male");
const [generatedImageUrl, setGeneratedImageUrl] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false);
const [predictionId, setPredictionId] = useState<string | null>(null);
const [userTokens, setUserTokens] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const searchParams = useSearchParams();
const [statusMessage, setStatusMessage] = useState<{ type: 'success' | 'error', message: string } | null>(null);
useEffect(() => {
const success = searchParams.get('success');
const error = searchParams.get('error');
if (success === 'subscription_active') {
setStatusMessage({ type: 'success', message: 'Your subscription is now active!' });
} else if (error) {
setStatusMessage({ type: 'error', message: 'There was an error processing your subscription. Please try again.' });
}
if (session) {
fetchUserTokens();
}
}, [searchParams, session]);
const fetchUserTokens = async () => {
try {
const response = await fetch('/api/user/tokens');
if (response.ok) {
const data = await response.json();
setUserTokens(data.tokens);
}
} catch (error) {
console.error('Error fetching user tokens:', error);
}
};
const handleUploadComplete = (url: string, gender: string) => {
setUploadedImageUrl(url);
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
setError(null);
};
const handleGenderChange = (gender: string) => {
setSelectedGender(gender);
setGeneratedImageUrl(null);
setPredictionId(null);
setError(null);
};
const handleGenerateHeadshot = async () => {
if (!uploadedImageUrl) {
setError("Please upload an image first.");
return;
}
if (userTokens !== null && userTokens <= 0) {
setError("You don't have enough tokens. Please upgrade your plan.");
return;
}
setIsGenerating(true);
setError(null);
try {
const response = await fetch('/api/replicate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ uploadedImageUrl, gender: selectedGender }),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || 'Failed to start image generation');
}
const data = await response.json();
setPredictionId(data.predictionId);
setUserTokens(data.remainingTokens);
} catch (error) {
console.error("Error starting headshot generation:", error);
setError(error instanceof Error ? error.message : 'An unknown error occurred');
setIsGenerating(false);
}
};
const handleButtonClick = () => {
if (userTokens === 0) {
// Navigate to the pricing page
window.location.href = '/pricing';
} else {
handleGenerateHeadshot();
}
};
useEffect(() => {
if (!predictionId || !uploadedImageUrl) return;
const checkStatus = async () => {
try {
const response = await fetch(`/api/replicate/status?id=${predictionId}&originalImageUrl=${encodeURIComponent(uploadedImageUrl)}&gender=${selectedGender}`);
if (!response.ok) {
throw new Error('Failed to check prediction status');
}
const data = await response.json();
if (data.status === 'complete') {
setGeneratedImageUrl(data.output);
setIsGenerating(false);
setPredictionId(null);
await fetchUserTokens(); // Refresh token count after successful generation
} else if (data.status === 'failed') {
throw new Error(data.error || 'Image generation failed');
} else {
// Still processing, check again in 2 seconds
setTimeout(checkStatus, 2000);
}
} catch (error) {
console.error("Error checking prediction status:", error);
setError(error instanceof Error ? error.message : 'An unknown error occurred');
setIsGenerating(false);
setPredictionId(null);
}
};
checkStatus();
}, [predictionId, uploadedImageUrl, selectedGender]);
return (
<div className="container mx-auto py-10 space-y-6">
<h1 className="text-2xl sm:text-3xl font-bold">AI Headshot Generator</h1>
{statusMessage && (
<Alert variant={statusMessage.type === 'success' ? 'default' : 'destructive'}>
<AlertDescription>{statusMessage.message}</AlertDescription>
</Alert>
)}
{userTokens !== null && (
<p className="text-lg font-semibold">Available tokens: {userTokens}</p>
)}
<Card>
<CardHeader>
<CardTitle>Upload Your Photo</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Upload onUploadComplete={handleUploadComplete} onGenderChange={handleGenderChange} />
{uploadedImageUrl && (
<Button
onClick={handleButtonClick}
disabled={isGenerating || (userTokens !== null && userTokens < 0)}
className="w-full sm:w-auto"
>
{userTokens === 0 ? "Get Tokens" : isGenerating ? "Generating..." : "Generate Headshot"}
</Button>
)}
{error && (
<Alert variant="destructive">
<AlertDescription>
{error}
{(error.toLowerCase().includes('no active subscription') || error.toLowerCase().includes('don\'t have enough tokens')) && (
<>
<br />
<Link href="/pricing" className="underline font-semibold">
View our pricing plans
</Link>
</>
)}
</AlertDescription>
</Alert>
)}
<div className="space-y-4">
<h3 className="text-lg font-semibold">Before and After</h3>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="space-y-2">
<h4 className="text-md font-medium">Original Image</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[200px] sm:h-[300px]">
{uploadedImageUrl ? (
<img src={uploadedImageUrl} alt="Original Image" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground text-center">Upload an image to see it here</p>
)}
</div>
</div>
<div className="space-y-2">
<h4 className="text-md font-medium">Generated Headshot</h4>
<div className="bg-muted rounded-lg p-4 flex items-center justify-center h-[200px] sm:h-[300px]">
{isGenerating ? (
<p className="text-center">Generating headshot...</p>
) : generatedImageUrl ? (
<img src={generatedImageUrl} alt="Generated Headshot" className="max-w-full max-h-full object-contain" />
) : (
<p className="text-muted-foreground text-center">Your generated headshot will appear here</p>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
After the stripe payment goes through, we need to handle the successful payment, start with another route:
// app/api/stripe/success/route.ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import dbConnect from '@/lib/dbConnect';
import User from '@/models/User';
export async function GET(req: Request) {
const { searchParams } = new URL(req.url);
const sessionId = searchParams.get('session_id');
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
if (!sessionId) {
console.error('Missing session_id in success route');
return NextResponse.redirect(`${baseUrl}/dashboard?error=missing_session_id`);
}
try {
await dbConnect();
const session = await stripe.checkout.sessions.retrieve(sessionId);
console.log('Retrieved Stripe session:', sessionId);
const userId = session.client_reference_id;
if (!userId) {
console.error('No client_reference_id found on session:', sessionId);
throw new Error('No client_reference_id found on session');
}
const user = await User.findById(userId);
if (!user) {
console.error('No user found for ID:', userId);
throw new Error('No user found');
}
// Retrieve the product details to determine the plan
const lineItems = await stripe.checkout.sessions.listLineItems(sessionId);
const priceId = lineItems.data[0]?.price?.id;
console.log('Subscription price ID:', priceId);
// Assign tokens based on the plan
let tokensToAssign = 0;
if (priceId === process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID) {
tokensToAssign = Number(process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_HEADSHOTS) || 5;
} else if (priceId === process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_ID) {
tokensToAssign = Number(process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_HEADSHOTS) || 15;
} else {
console.warn('Unknown price ID:', priceId);
}
console.log('Assigning tokens:', tokensToAssign);
// Update user subscription status and tokens
user.isSubscriptionActive = true;
user.stripePlanId = priceId;
user.stripeSubscriptionId = session.subscription as string;
user.stripeCustomerId = session.customer as string;
user.tokens = tokensToAssign;
await user.save();
console.log('User updated successfully:', userId);
// Redirect to dashboard with success message
return NextResponse.redirect(`${baseUrl}/dashboard?success=subscription_active`);
} catch (error) {
console.error('Error processing successful subscription:', error);
return NextResponse.redirect(`${baseUrl}/dashboard?error=subscription_error`);
}
}
We also want to put pricing on our landing page, so a quick component for that as well:
// components/Pricing.tsx
import React from 'react';
import { useSession, signIn } from 'next-auth/react';
import { Card, CardHeader, CardContent, CardFooter } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Check } from "lucide-react";
import { loadStripe } from '@stripe/stripe-js';
const stripePromise = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY
? loadStripe(process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY)
: null;
const plans = [
{
name: "Standard",
price: `$${process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_PRICE}`,
features: [
`${process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_HEADSHOTS} AI-generated headshots per month`,
"Basic editing tools",
"Email support",
"720p resolution images"
],
priceId: process.env.NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID
},
{
name: "Pro",
price: `$${process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE}`,
features: [
`${process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_HEADSHOTS} AI-generated headshots per month`,
"Advanced editing tools",
"Priority email support",
"1080p resolution images",
"Custom backgrounds"
],
priceId: process.env.NEXT_PUBLIC_STRIPE_PRO_PLAN_ID
}
];
const Pricing: React.FC = () => {
const { data: session } = useSession();
const handleSubscribe = async (priceId: string) => {
if (!session) {
// If not signed in, start the sign-in process
signIn(undefined, { callbackUrl: `/api/create-checkout-session?priceId=${priceId}` });
} else {
// If signed in, redirect to checkout
await startCheckout(priceId);
}
};
const startCheckout = async (priceId: string) => {
try {
const response = await fetch('/api/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ priceId }),
});
if (!response.ok) {
throw new Error('Failed to create checkout session');
}
const { sessionId } = await response.json();
const stripe = await stripePromise;
if (!stripe) {
throw new Error('Stripe failed to initialize');
}
const { error } = await stripe.redirectToCheckout({ sessionId });
if (error) {
throw error;
}
} catch (error) {
console.error('Error:', error);
alert('An error occurred. Please try again.');
}
};
return (
<div className="container mx-auto py-10" id="pricing">
<h2 className="text-3xl font-bold text-center mb-10">Choose Your Plan</h2>
<div className="grid md:grid-cols-2 gap-8">
{plans.map((plan) => (
<Card key={plan.name} className="flex flex-col">
<CardHeader>
<h3 className="text-2xl font-bold">{plan.name}</h3>
<p className="text-4xl font-bold">{plan.price}<span className="text-base font-normal">/month</span></p>
</CardHeader>
<CardContent className="flex-grow">
<ul className="space-y-2">
{plan.features.map((feature, index) => (
<li key={index} className="flex items-center">
<Check className="mr-2 h-4 w-4 text-green-500" />
<span>{feature}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Button
className="w-full"
onClick={() => handleSubscribe(plan.priceId!)}
>
Buy Now
</Button>
</CardFooter>
</Card>
))}
</div>
</div>
);
};
export default Pricing;
Now that all the work to take payments is done, we also need to let users MANAGE their subs, so in your stripe settings go to billing and activate the customer portal. You can find the setting here: https://dashboard.stripe.com/settings/billing/portal
This needs to be done both on your test and live settings.
While we’re on stripe, you also want to set up your stripe webhook.
If you testing locally just open a new terminal and enter this command:
stripe listen --forward-to localhost:3000/api/webhooks/stripe
This will create a local webhook listener. YOu can take the generated whsec_ code into your .env.local.
For your live environment, go to developers on stripe, webhooks, create endpoint and put in https://yourdomain.com/api/webhooks/stripe
While you’re there set up your plans in products, then go back to dev and grab your publishable and secret stripe keys and update your .env.local (Or vercel environmental variables for production) using this template in the .env:
# Stripe Configuration
STRIPE_SECRET_KEY=
STRIPE_PUBLISHABLE_KEY=
STRIPE_WEBHOOK_SECRET=
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=
# Stripe Product and Price IDs
NEXT_PUBLIC_STRIPE_STANDARD_PLAN_ID=price_
NEXT_PUBLIC_STRIPE_PRO_PLAN_ID=price_
# Stripe Plan Details
NEXT_PUBLIC_STRIPE_STANDARD_PLAN_PRICE=10
NEXT_PUBLIC_STRIPE_PRO_PLAN_PRICE=20
NEXT_PUBLIC_STRIPE_STANDARD_PLAN_HEADSHOTS=5
NEXT_PUBLIC_STRIPE_PRO_PLAN_HEADSHOTS=15
Back to setting up the billing portal, create a billing portal route:
// app/api/create-portal-session/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from 'next-auth/next';
import { authOptions } from '@/lib/auth';
import Stripe from 'stripe';
import User from '@/models/User';
import dbConnect from '@/lib/dbConnect';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
});
function ensureHttps(url: string): string {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
}
export async function POST() {
await dbConnect();
const session = await getServerSession(authOptions);
if (!session || !session.user || !session.user.email) {
return NextResponse.json({ error: 'You must be logged in.' }, { status: 401 });
}
try {
// Fetch user from database
const user = await User.findOne({ email: session.user.email });
if (!user || !user.stripeSubscriptionId) {
return NextResponse.json({ error: 'No active subscription found.' }, { status: 400 });
}
// Fetch the subscription from Stripe
const subscription = await stripe.subscriptions.retrieve(user.stripeSubscriptionId);
if (!subscription.customer) {
return NextResponse.json({ error: 'No customer associated with the subscription.' }, { status: 400 });
}
// Ensure the customer is a string (it can be a string or a Stripe.Customer object)
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Ensure the return_url has a proper scheme
const returnUrl = ensureHttps(`${process.env.NEXT_PUBLIC_APP_URL}/dashboard`);
// Create a Stripe customer portal session
const portalSession = await stripe.billingPortal.sessions.create({
customer: customerId,
return_url: returnUrl,
});
// Return the URL of the portal session
return NextResponse.json({ url: portalSession.url });
} catch (error) {
console.error('Error creating portal session:', error);
const errorMessage = error instanceof Error ? error.message : 'An unknown error occurred';
return NextResponse.json({ error: 'Failed to create portal session.', details: errorMessage }, { status: 500 });
}
}
And update your user button component to have the billing button work:
// components/UserButton.tsx
"use client";
import { useState } from 'react';
import { useSession, signOut } from 'next-auth/react';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
export function UserButton() {
const { data: session } = useSession();
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
if (!session) return null;
const handleBillingClick = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/create-portal-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Failed to create portal session');
}
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('No URL returned from the server');
}
} catch (error) {
console.error('Error redirecting to customer portal:', error);
let errorMessage = 'Unable to access billing portal. Please try again later.';
if (error instanceof Error) {
if (error.message.includes('No active subscription found')) {
errorMessage = 'You don\'t have an active subscription. Please subscribe to a plan first.';
} else if (error.message.includes('No customer associated with the subscription')) {
errorMessage = 'There was an issue with your subscription. Please contact support.';
} else if (error.message.includes('Invalid URL')) {
errorMessage = 'There was an issue with the application configuration. Please contact support.';
}
}
alert(errorMessage);
} finally {
setIsLoading(false);
}
};
return (
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 px-2">
{session.user?.name}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuItem className="font-normal">
{session.user?.name}
</DropdownMenuItem>
<DropdownMenuItem onSelect={handleBillingClick} disabled={isLoading}>
{isLoading ? 'Loading...' : 'Billing'}
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => signOut()}>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
DONE! You now have a fully functional SaaS app.
I’ve made one last component for your landing page, just need to upload the before and after images to upload thing.
// components/BeforeAfter.tsx
import Image from 'next/image';
interface BeforeAfterProps {
beforeUrl: string;
afterUrl: string;
beforeAlt: string;
afterAlt: string;
}
export default function BeforeAfter({ beforeUrl, afterUrl, beforeAlt, afterAlt }: BeforeAfterProps) {
return (
<section className="bg-white py-20" id="before-after">
<div className="container mx-auto px-4">
<h2 className="text-3xl font-bold mb-12 text-center text-gray-800">Before & After</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-10">
<div className="bg-gray-100 p-8 rounded-lg shadow-md">
<h3 className="text-xl font-bold mb-4 text-indigo-600">Before</h3>
<div className="relative w-full aspect-square">
<Image
src={beforeUrl}
alt={beforeAlt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="rounded-lg object-cover"
/>
</div>
</div>
<div className="bg-gray-100 p-8 rounded-lg shadow-md">
<h3 className="text-xl font-bold mb-4 text-indigo-600">After</h3>
<div className="relative w-full aspect-square">
<Image
src={afterUrl}
alt={afterAlt}
fill
sizes="(max-width: 768px) 100vw, 50vw"
className="rounded-lg object-cover"
/>
</div>
</div>
</div>
</div>
</section>
);
}
Then update your landing page to include it (I’ve also added pricing and moved a few components):
// app/page.tsx
"use client";
import React from 'react';
import dynamic from 'next/dynamic';
import TopNav from '../components/TopNav';
import Footer from '../components/Footer';
import Hero from '../components/Hero';
import CallToAction from '../components/CTA';
import SocialProof from '../components/SocialProof';
import Benefits from '../components/Benefits';
import Features from '../components/Features';
import Pricing from '../components/Pricing';
import BeforeAfter from '../components/BeforeAfter';
const LeadForm = dynamic(() => import('../components/LeadForm'), { ssr: false });
export default function Home() {
return (
<div className="flex flex-col min-h-screen">
<TopNav />
<main className="font-sans">
<Hero />
<BeforeAfter
beforeUrl="https://utfs.io/f/39336f23-f117-47d1-8713-8de14e48bf89-6shtvj.jpg"
afterUrl="https://utfs.io/f/47e150b8-a9af-400e-95bd-012600a6cd74-vmbdcc.png"
beforeAlt="Before AI enhancement"
afterAlt="After AI enhancement"
/>
<SocialProof
testimonials={[
{ name: "John Doe", role: "Marketing Manager", text: "This AI headshot generator saved me time and money. The results are impressive!" },
{ name: "Jane Smith", role: "Software Engineer", text: "I was skeptical at first, but the quality of the headshots exceeded my expectations." },
{ name: "Mike Johnson", role: "Freelance Consultant", text: "As a freelancer, this tool helps me maintain a professional image across all platforms." }
]}
/>
<CallToAction
text="Ready to upgrade your professional image?"
buttonText="Generate Your Headshot"
/>
<Benefits />
<CallToAction
text="Join thousands of professionals who've upgraded their image"
buttonText="Start Now"
/>
<Features />
<Pricing />
<SocialProof
testimonials={[
{ name: "Sarah Lee", role: "HR Specialist", text: "We use this for all our employee profiles. It's consistent and professional." },
{ name: "Tom Brown", role: "Recent Graduate", text: "This tool helped me create a great first impression for job applications." },
{ name: "Emily Chen", role: "Entrepreneur", text: "Quick, easy, and effective. Perfect for busy professionals like me." }
]}
/>
<CallToAction
text="Ready to transform your professional image?"
buttonText="Generate Your Headshot Now"
/>
<LeadForm
title="Join Our Community"
subtitle="Sign up now to get exclusive offers and discounts"
buttonText="Join Our Community"
/>
</main>
<Footer />
</div>
);
}
Remember, you can find the link to full source code here: https://www.swipesaas.com