I recently embarked on a side project to create a sleek newsletter landing page template. This template can be a waitlist page or a newsletter landing page, and it was built using Next.js, Tailwind CSS, TypeScript, Headless UI, and Framer Motion.
You can follow the steps below, or you can see the full code in the GitHub Repo. If you do check it out, then don't forget the star the project 😉
Live Demo
https://next-kit-form.vercel.app/
Why A Newsletter Landing Page?
Email is still one of the most direct and personal ways to reach people. Whether you're launching a SaaS product, sharing your latest blog posts, or gauging interest in a new software idea, having direct access to your audience's inbox is invaluable.
A newsletter or waitlist page is the best way to move from "borrowing" an audience on platforms like YouTube, Twitter and Instagram to owning our audience through email.
Let's dive into how I built this landing page, focusing on the key components and the reasoning behind each step.
Creating The Hero Section
The hero section is the first thing visitors see, so it needs to make an impact. I wanted it to be visually appealing, informative, and prompt visitors to take action.
Here's How I Approached It:
-
Framer Motion: I used Framer Motion to add subtle animations, making the entrance of the hero section more engaging. It's like giving your page a friendly wave as visitors arrive.
-
Tailwind CSS: Styling with Tailwind allowed me to keep the JSX clean while rapidly iterating on the design. The utility classes make it easy to adjust spacing, colors, and typography without writing additional CSS.
-
Responsive Design: Classes like md:text-7xl ensure the text scales appropriately on larger screens, providing a consistent experience across devices.
Dependencies Used:
npm install @headlessui/react framer-motion
Now, onto the main page.
// app/page.tsx
'use client';
import { useState } from 'react';
import { motion } from 'framer-motion';
export default function HomePage() {
return (
<div className="pt-16">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1 }}
className="text-center"
>
<div className="shadow-lg font-semibold mb-4 inline-flex rounded-xl px-4 py-1 md:py-2 text-xs md:text-sm leading-6 text-gray-400 ring-1 ring-white/20 hover:ring-white/30 backdrop-blur-xl bg-white/10 transition-all">
Ideal for SaaS waitlists & content creator newsletters.
</div>
<h1 className="text-5xl md:text-7xl font-bold tracking-tight max-w-4xl mx-auto text-transparent bg-clip-text bg-gradient-to-r from-gray-900 to-white">
Next.js Newsletter Landing Page
</h1>
<p className="mt-4 text-lg text-gray-400">
A simple Next.js newsletter landing page with built-in API options.
</p>
</motion.div>
</div>
);
}
The Subscription Form
Next up is the subscription form, the gateway to owning your audience. I wanted to write the from in a way that you can easily Copypasta it in a component.
Here's What I Did:
-
Validation Function: isValidEmail uses a simple regex to check the email format. This prevents obvious invalid entries before we even hit the API.
-
State Management: We track email, status, and emailError using React's useState. This allows us to provide immediate feedback to the user.
-
Error Handling: If the email is invalid, we display an error message and highlight the input field with a red border. Subtle yet effective.
-
Async Submission: The handleSubmit function sends a POST request to our API route /api/subscribe. We use try...catch to handle any errors gracefully.
-
Feedback Messages: Depending on the status, we show success or error messages using Framer Motion for a smooth appearance.
// app/page.tsx (continued)
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export default function HomePage() {
const [email, setEmail] = useState('');
const [status, setStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const [emailError, setEmailError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setEmailError('');
if (!isValidEmail(email)) {
setEmailError('Please enter a valid email address.');
return;
}
setStatus('loading');
try {
const res = await fetch('/api/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Something went wrong');
}
setStatus('success');
setEmail('');
} catch (error) {
console.error(error);
setStatus('error');
}
};
return (
// ...previous code
<div className="max-w-3xl mx-auto mt-10 pb-12">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="bg-white/10 backdrop-blur-xl rounded-3xl p-8 md:p-14 shadow-xl"
>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="email"
placeholder="Email address"
value={email}
onChange={(e)=> {
setEmail(e.target.value);
if (emailError && isValidEmail(e.target.value)) {
setEmailError('');
}
}}
required
className={`w-full px-4 py-2 rounded-lg bg-white/10 border ${
emailError ? 'border-red-400' : 'border-white/20'
} text-white placeholder:text-white/40 focus:border-white/40 focus:ring-0`}
/>
{emailError && <p className="text-sm text-red-400">{emailError}</p>}
<button
type="submit"
disabled={status= 'loading'}
className="w-full px-4 py-2 rounded-lg bg-white text-black hover:bg-white/90 transition-colors font-medium"
>
{status === 'loading' ? 'Joining...' : 'Join Waitlist'}
</button>
</form>
{status === 'success' && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center text-sm text-green-400"
>
Thank you for joining. We'll be in touch soon.
</motion.p>
)}
{status === 'error' && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-center text-sm text-red-400"
>
Something went wrong. Please try again.
</motion.p>
)}
</motion.div>
</div>
);
}
Getting Your KIT (Formerly ConvertKit) Secret And Form ID
To integrate your landing page with ConvertKit, you'll need two key pieces of information:
- ConvertKit API Secret
- ConvertKit Form ID
So head to Kit, create an account and select the free "newsletter" pricing plan:
- Get Your ConvertKit API Secret
The API Secret allows your server to communicate securely with ConvertKit's API.
- In the settings sidebar, click on "Advanced".
- Under the "API Key" section, you'll find both your API Key and API Secret.
- Copy the API Secret.
- Find Your ConvertKit Form ID
The Form ID tells ConvertKit which form to add subscribers to.
- Navigate to the "Landing Pages & Forms" section.
- Create a new form or select an existing one.
- Get the Form ID from the URL.
After opening your form, look at the URL in your browser's address bar. It will look something like this:
https://app.convertkit.com/forms/1234567/edit
The number after /forms/ and before /edit is your Form ID. In this example, the Form ID is 1234567.
Copy both pieces of information and added to your environment variables:
Environment Variables:
Create a ".env.local"
file (which should never be committed to version control), and add your info:
CONVERTKIT_API_SECRET=your_convertkit_api_secret
CONVERTKIT_FORM_ID=your_form_id_number
This keeps sensitive information out of the client-side codebase.
Integrating The KIT API (Formerly ConvertKit) And The Route.ts File
Now, let's connect our form to ConvertKit to actually collect those emails.
Quick Summary:
-
Server-Side Processing: By handling the subscription on the server, we keep our API keys secure and away from prying eyes (and bots).
-
Environment Variables: We use process.env to access our ConvertKit API key and Form ID, which are stored securely in a .env.local file.
-
Error Handling: The API route provides meaningful error messages and status codes, making it easier to debug issues and inform the user appropriately.
We create an API route in "app/api/subscribe/route.ts"
:
// app/api/subscribe/route.ts
import { NextResponse } from 'next/server';
function isValidEmail(email: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}
export async function POST(request: Request) {
const { email } = await request.json();
if (!email || !isValidEmail(email)) {
return NextResponse.json({ error: 'Invalid email address' }, { status: 400 });
}
const API_SECRET = process.env.CONVERTKIT_API_SECRET;
const FORM_ID = process.env.CONVERTKIT_FORM_ID;
if (!API_SECRET || !FORM_ID) {
console.error('ConvertKit API secret or form ID not set');
return NextResponse.json({ error: 'Server configuration error' }, { status: 500 });
}
try {
const response = await fetch(`https://api.convertkit.com/v3/forms/${FORM_ID}/subscribe`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
api_secret: API_SECRET,
email: email,
}),
});
if (!response.ok) {
const errorData = await response.json();
return NextResponse.json({ error: errorData.message || 'Error subscribing' }, { status: response.status });
}
return NextResponse.json({ message: 'Subscribed successfully' });
} catch (error) {
console.error('Error subscribing:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Adding Animations With Framer Motion
I'll be the first one to say that this is completely unnecessary, but animations enhance user experience by providing visual feedback and guiding the user's attention. Long story short, it looks cool man.
Key Points:
-
Subtle Animations: Using gentle scale and opacity changes makes the UI feel responsive without being distracting.
-
User Feedback: Animations on success and error messages help convey the state change smoothly.
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
className="bg-white/10 backdrop-blur-xl rounded-3xl p-8 md:p-14 shadow-xl"
>
{/* Form and messages */}
</motion.div>
Wrapping Up
In the end, this project wasn't just about building a landing page; it was about leveraging the right tools to create something efficient, scalable, and user-friendly. It reinforced the idea that with the right stack, we can focus more on solving problems and less on fighting the framework.
I hope this walkthrough has been insightful and inspires you to experiment with these technologies in your own projects. Let's keep pushing the boundaries of what's possible with web development!
Deployment
Clone & Run Locally
First, execute create-next-app with npx to bootstrap the example:
npx create-next-app --example https://github.com/JPerez00/next-kit-form your-project-name-here
Create a ".env.local"
file (which should never be committed to version control), and add your info:
CONVERTKIT_API_SECRET=your_convertkit_api_secret
CONVERTKIT_FORM_ID=your_form_id_number
Then run the development server:
npm run dev
Open http://localhost:3000 with your browser to see the result.
Clone & Deploy
When deploying the project to Vercel, add the same environment variable to your Vercel project.
Navigate to your Vercel dashboard, select your project, go to the "Settings" tab, and then to "Environment Variables."
CONVERTKIT_API_SECRET=your_convertkit_api_secret
CONVERTKIT_FORM_ID=your_form_id_number