Harness the Power of Google Gemini AI API to Create a Dynamic Landing Page Template.

AI has become an integral part of everyone’s life, and it’s impossible to ignore its impact. I’ve seen many people use the Gemini AI API for fun projects, like building chatbots. I scratched my head and wanted to create something a little bit different. I kept brainstorming for a unique idea. Then one day, last year, I stumbled upon Framer AI. It automatically generates a landing page for you based on a simple prompt. (Back then, it was a standalone website, but now they’ve integrated this feature directly into their project. Command + K, You should check out the demo.)

framer ai demo

This AI-generated landing page is one of the best products I’ve come across — it’s truly inspiring. After experimenting with various prompts and testing different approaches, I now have a good sense of how to leverage the Google Gemini AI API to create something exciting and innovative. My goal is to build something beyond just another chatbot. 😉

demo that I built

Here is the link to my demo, I am still actively building it at my leisure and my pace. As we all know, no matter the website is under which category, it is built based on several basic UI elements, text, images, buttons, etc., and several sections, eg, header, footers, hero, and other varied dynamic sections based on the different themes. Then I came up with the idea of how to structure my project.

Step 1: Remix Form Element and Input Bar

First thing first. I take advantage of the Remix Form element to handle POST requests for user input. Below is the code for the Input component, an input searching bar on the page that includes functionality for form submission, success/failure states, and loading animations, and then add some styling later.

Here is the /components/Input.tsx

import { useFetcher } from '@remix-run/react';
import { useEffect, useRef, useState } from 'react';
import Grid from '~/components/Grid';
import RandomSvgShapes from './RandomSvgShapes';
import Newsletter from './Newsletter';

import Lottie, { useLottie } from 'lottie-react';
import skeletonLoading from '../../public/skeleton loading.json';


export default function Input() {
const fetcher = useFetcher();
const succeeded = fetcher?.data?.data?.photos;
const [imageData, setImageData] = useState([]);
const [showSuccessMsg, setShowSuccessMsg] = useState(succeeded);
const [query, setQuery] = useState('');
const [show, setShow] = useState(false);

const state: 'idle' | 'success' | 'error' | 'submitting' = fetcher?.data?.data
?.photos
? 'success'
: fetcher?.data?.formError
? 'error'
: fetcher.state == 'submitting'
? 'submitting'
: 'idle';

const inputRef = useRef<HTMLInputElement>(null);
const queryRef = useRef<HTMLInputElement>(null);

useEffect(() => {
if (state == 'success') {
setImageData(fetcher?.data?.data?.photos);
//@ts-ignore
inputRef.current?.reset();
}
}, [fetcher?.data?.data?.photos, state]);

useEffect(() => {
if (fetcher.state === 'submitting') {
setShow(true);
}
}, [fetcher.state]);

useEffect(() => {
let timeout: number;
if (succeeded && inputRef.current) {
setShowSuccessMsg(true);
//@ts-ignore
inputRef.current.reset();
timeout = window.setTimeout(() => setShowSuccessMsg(false), 2000);
} else {
setShowSuccessMsg(false);
}
return () => {
if (timeout) window.clearTimeout(timeout);
};
}, [succeeded]);

const [loadingTimeout, setLoadingTimeout] = useState(null);
const [showLoading, setShowLoading] = useState(false);

const handleFormSubmit = () => {
setShowLoading(true); // Display the loading message
setLoadingTimeout(
//@ts-ignore
setTimeout(() => {
setShowLoading(false); // Hide the loading message after 2 seconds
}, 9000)
);
};

useEffect(() => {
return () => {
if (loadingTimeout) {
clearTimeout(loadingTimeout);
}
};
}, [loadingTimeout]);

return (
<main>
<fetcher.Form
replace
method="post"
aria-hidden={state === 'success'}
action="/api/testai"
ref={inputRef}
className="w-full shrink-0 grow-0 basis-auto px-3 "
>
<div className="absolute inset-0 flex items-center justify-center flex-col">
<div className="text-center text-6xl text-white">
<a href="/">Website Landing Page Generate AI</a>
</div>
<div className="grid items-center gap-x-6 w-8/12">
<div className="text-center"></div>
<div>
<fieldset className="my-6 flex-row md:mb-0 md:flex">
<input
aria-label="prompt"
aria-describedby="error-message"
type="prompt"
name="query"
placeholder="I want to create a SaaS landing page."
className="border border-blue-100 w-full px-2"
required
/>
<button
type="submit"
onClick={handleFormSubmit}
className="w-1/2 px-8 py-2 md:mx-4 bg-black text-white my-2 md:my-0"
>
{state === 'submitting' ? 'Sending...' : 'Try our AI'}
</button>
</fieldset>
</div>
</div>
</div>

{showLoading && (
<div className="absolute inset-x-0 bottom-0 flex items-center justify-center text-9xl">
Loading...
</div>
)}
</fetcher.Form>
</main>
);
}

In my code, I made a POST request to the route action=”/api/testai”, which handles my search query. In Remix, when naming a route with a dot (e.g., api.test.tsx), it converts the route name to a forward slash (i.e., /api/testai).

Step 2: Handling the Search Query and Redirect

Here’s the corresponding file routes/api.test.tsx.

import { json, redirect } from '@remix-run/node';
import type { ActionFunction } from '@remix-run/node';

type ActionData = {
formError?: string;
};

const badRequest = (data: ActionData) => json(data, { status: 400 });

export const action: ActionFunction = async ({ request, context }: any) => {
await new Promise((res) => setTimeout(res, 2000));
const formData = await request.formData();
const query = formData.get('query');

if (Object.keys(query).length === 0) {
return redirect('/');
}

if (!query || typeof query !== 'string') {
return badRequest({
formError: 'Please provide an valid prompt string.',
});
}
if (query === '') {
return badRequest({
formError: 'The input can not be empty',
});
}

return redirect(`/project?q=${query}`);
};

I encountered my first obstacle while building the application: I wanted to append the user’s query to the URL as a query string to provide a clear indication of what the application was going to generate while at the same time making the API call to Gemini AI. After much trial and error, this was the only way that I could make it.

append search query to the URL

As shown in the screenshot, I used redirect(`/project?q=${query}`) in Remix to navigate to the routes/project.tsx route. The appended query string ensures that the request is correctly routed to /project.tsx. Here is the /routes/project.tsx file.

import { redirect, type LoaderArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { useEffect } from 'react';
import Grid from '~/components/Grid';

export const loader = async ({ request }: LoaderArgs) => {
const searchParams = new URL(request.url).searchParams;

const searchTerm = searchParams.get('q')!;

if (!searchTerm || searchTerm == '') {
return redirect('/');
}

const baseUrl =
process.env.NODE_ENV == 'development'
? 'http://localhost:3000'
: 'https://website-generate-ai.vercel.app';

try {
const res = await fetch(`${baseUrl}/api/project?q=${searchTerm}`);

if (res.ok) {
const contentType = res.headers.get('content-type');

if (contentType && contentType.includes('application/json')) {
const data = await res.json();
return data;
} else {
// Handle non-JSON response
return { error: 'Non-JSON response from the server.' };
}
} else {
throw new Error(`Fetch failed with status: ${res.status}`);
}
} catch (error) {
console.error('Error in loader function:', error);
return { error: 'An error occurred while fetching data.' };
}
};

export default function Project() {
const dataLoader = useLoaderData();

const { data, profileData, colors, richText, navBar } = dataLoader ?? {};

useEffect(() => {
if (colors) {
document.body.style.backgroundColor = colors.color1;
} else {
return;
}
}, [colors]);

return (
<div>
<Grid
data={data?.photos}
colors={colors}
profile={profileData}
richText={richText}
navBar={navBar}
/>
</div>
);
}

In the code above, I finally make the API call,

const res = await fetch(`${baseUrl}/api/project?q=${searchTerm}`);

Step 3: Fetching Data from Gemini AI && Step 4: Concurrent API Requests With Promise.all()

Then, here is the /routes/api.project.tsx file.

import { json, redirect } from '@remix-run/node';
import type { LoaderArgs } from '@remix-run/node';
import nlp from 'compromise/three';
import { GoogleGenerativeAI } from '@google/generative-ai';

const { GoogleAuth } = require('google-auth-library');

type ActionData = {
formError?: string;
};

interface ColorPair {
color1: string;
color2: string;
}

const badRequest = (data: ActionData) => json(data, { status: 400 });

export const loader = async ({ request, context }: LoaderArgs) => {
const url = new URL(request.url);
const searchParams = new URLSearchParams(url.search);

const query = searchParams.get('q')!;

if (Object.keys(query).length === 0) {
return redirect('/');
}

if (!query || query.trim() === '') {
return badRequest({ formError: 'Query is required' });
}

if (query == null) {
console.log("The 'q' parameter is empty or does not exist.");
}

const doc = nlp(query).verbs().out('array');
if (doc.length === 0) {
return badRequest({ formError: 'Could not extract verbs from the prompt' });
}

if (!query || query.trim() === '') {
return badRequest({ formError: 'The input cannot be empty' });
}
if (query === '') {
return badRequest({
formError: 'The input can not be empty',
});
}

async function generateText(
prompt = '',
client = new GoogleGenerativeAI(process.env.PALM_API),
MODEL_NAME = 'gemini-1.5-flash'
) {
let output;
try {
const model = client.getGenerativeModel({ model: MODEL_NAME });
const result = await model.generateContent(prompt);
return result;
} catch (error) {
console.error('Error:', error);
return json(
{ error: 'Failed to retrieve data from API.' },
{ status: 500 }
);
}

return output;
}

async function extractMainPhrase(sentence: string) {
// Define a list of common stop words that we want to skip
const stopWords = [
'a',
'an',
'the',
'in',
'on',
'at',
'to',
'of',
'with',
'for',
'i',
];

// Tokenize the sentence into words
const words = sentence.split(' ');
// Initialize an array to store the main phrase
const mainPhrase: string[] = [];

// Iterate through the words in the sentence
for (let i = 0; i < words.length; i++) {
const word = words[i].toLowerCase();
// If the word is not a stop word and is a noun (singular or plural), add it to the main phrase
if (!stopWords.includes(word) && !doc.includes(word)) {
mainPhrase.push(words[i]);
} else {
// If we encounter a stop word or non-noun word, stop and return the main phrase
continue;
}
}

// Join the main phrase words to form the main phrase
const mainPhraseText = mainPhrase.join(' ');

return mainPhraseText;
}

const checkedQuery = await extractMainPhrase(query);
function generateRandomColor(): string {
return '#' + Math.floor(Math.random() * 16777215).toString(16);
}

function generateComplementaryColor(color: string): string {
color = color.replace('#', '');
const r = parseInt(color.slice(0, 2), 16);
const g = parseInt(color.slice(2, 4), 16);
const b = parseInt(color.slice(4, 6), 16);
const compR = 255 - r;
const compG = 255 - g;
const compB = 255 - b;
return `#${compR.toString(16)}${compG.toString(16)}${compB.toString(16)}`;
}

async function generateRandomComplementaryColors(): Promise<ColorPair> {
const newColor1 = generateRandomColor();
const newColor2 = generateComplementaryColor(newColor1);
return { color1: newColor1, color2: newColor2 };
}

const fullContents = `I want to create a landing page for ${checkedQuery}, i need heading, subheading, body text and cta button text of each of the sections. Make sure the total sections are no less than six. Heading should be limited to 20 characters. The cta content should be limit to 60 characters. The body content should be around 250 characters to 400 characters. Do not generate dummy text for the body. Instead generating meaningful text for the body. For the hero section, make sure the heading must be called "Hero". Make the heading capitalized. put the data under sections. formatted data as an array without writing the word json and three backticks`;

const navBarItems = `I want to create a landing page for ${checkedQuery}, Provide me with only the names and href values of the navigation bar items. formatted data as an array without writing the word json and three backticks `;

if (fullContents == undefined) {
return;
}

const pexelsFetch = async (query: string, perPage: 5) => {
const res = await fetch(
`https://api.pexels.com/v1/search?query=${query}&per_page=5`,
{
method: 'GET',
//@ts-ignore
headers: { Authorization: process.env.PIXEL_API_KEY },
}
);

if (!res.ok) {
throw new Error(`Failed to fetch images. Status: ${res.status}`);
}

return res.json();
};

const peopleFetch = async (query: string) => {
const res = await fetch(
`https://api.pexels.com/v1/search?query=${query}&per_page=3`,
{
method: 'GET',
//@ts-ignore
headers: {
Authorization: process.env.PIXEL_API_KEY,
},
}
);

if (!res.ok) {
throw new Error(`Failed to fetch images. Status: ${res.status}`);
}

return res.json();
};

const profileQuery = 'people profile';

const [
fullContentsResult,
navBarItemsResult,
pexelsFetchResult,
profileFetchResult,
randomColors,
] = await Promise.all([
generateText(fullContents),
generateText(navBarItems),
pexelsFetch(checkedQuery, 5),
peopleFetch(profileQuery),
generateRandomComplementaryColors(),
]);

return {
data: pexelsFetchResult,
profileData: profileFetchResult,
colors: randomColors,

richText: JSON.parse(
fullContentsResult?.response?.candidates[0]?.content?.parts[0]?.text
),
navBar: JSON.parse(
navBarItemsResult?.response?.candidates[0]?.content?.parts[0]?.text
),
};
};

I’ve successfully implemented the Gemini API call, along with other API calls, using Promise.all() to run multiple asynchronous tasks concurrently. This approach is more efficient, especially when handling multiple independent API requests.

Step 5: Rendering the Generated Content

Finally, I take the data generated by Gemini AI, including the page content, images, and colors, and pass it to my Grid component to render the landing page dynamically:

export default function Project() {
const dataLoader = useLoaderData();

const { data, profileData, colors, richText, navBar } = dataLoader ?? {};

useEffect(() => {
if (colors) {
document.body.style.backgroundColor = colors.color1;
} else {
return;
}
}, [colors]);

return (
<div>
<Grid
data={data?.photos}
colors={colors}
profile={profileData}
richText={richText}
navBar={navBar}
/>
</div>
);
}

This is the code snippet from /routes/project.tsx file

In the next post, I’ll dive deeper into how you can further customize this landing page, generate more specific UI elements, and leverage other AI tools to make your projects stand out. Stay tuned for more insights on using the Gemini API for web development!


How to Randomly Generate a Website Landing Page With Google Gemini AI(Part One) was originally published in UX Planet on Medium, where people are continuing the conversation by highlighting and responding to this story.