Build an AI Content Repurposer with MongoDB

Repurpose any text into LinkedIn, Twitter, and Instagram posts with AI.

Introduction

30 Second Summary

You write one great piece of content, then spend the next hour rewriting it for LinkedIn, Twitter, and Instagram. Each platform wants a different length, tone, and format.

In this project, you will build an AI-powered content repurposer that transforms any text into platform-optimized posts. Every generation is saved to a MongoDB database, building a searchable content library over time.

What You'll Build

A fullstack AI Content Repurposer built with Next.js, the Vercel AI SDK, and MongoDB Atlas.

By the end of this project, you'll have:

  • A Next.js API route that uses generateObject with a Zod schema to produce structured, platform-specific content from Gemini.
  • A MongoDB Atlas database with a cached connection utility and a browsable content history panel.
  • A live Vercel deployment with MongoDB Atlas connected via environment variables, proving you can build and ship a fullstack AI product.
  • Secret Mission: Add tone selection (Professional, Casual, Witty) that changes the AI's output style, and display character counts with platform limit warnings.

Are there any prerequisites?

This project assumes you have completed the "Build an AI Dev Agent with Tool Calling" project, or have equivalent familiarity with Next.js, the Vercel AI SDK, and Zod schemas.

Before We Start

Before We Start...

Set Up Your Project and MongoDB Atlas

Every social media manager knows the pain of reformatting the same idea for three different platforms. Your content repurposer needs a foundation: a Next.js app for the fullstack framework, AI packages for content generation, and a database to store every generation.

In this step, you will scaffold the project, connect to a free MongoDB Atlas cloud database, and wire up environment variables so everything talks to each other.

In this step, get ready to:
  • Scaffold a Next.js project and install all dependencies.
  • Create a MongoDB Atlas account with a free cluster.
  • Configure environment variables and verify the dev server runs.
Scaffold a Next.js project and install dependencies

You need a Next.js project with the App Router and TypeScript enabled. The Vercel AI SDK handles AI generation, and the official mongodb driver connects to your database.

  • Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
  • Type Terminal and press Enter to open it.
  • Check your Node.js version by running this command:
node --version

You need version v20.19.0 or higher for the mongodb package to work.

Seeing a version lower than v20.19.0?

Visit nodejs.org and download the latest LTS version. Run the installer, then close and reopen your terminal before checking again.

help me upgrade Node.js to version 20.19.0 or higher

  • Move to your Desktop by running this command:
cd ~/Desktop
  • Create your Next.js project by running this command:
npx create-next-app@latest content-repurposer --yes

What does this command do?

The --yes flag accepts all default settings. This gives you TypeScript, Tailwind CSS, ESLint, and the App Router out of the box.

  • Move into the new project folder by running:
cd content-repurposer
  • Install the AI and database dependencies by running:
npm install ai @ai-sdk/google @ai-sdk/react mongodb zod

What are these packages?

  • ai is the core Vercel AI SDK with functions like generateObject.
  • @ai-sdk/google connects the SDK to Google's Gemini models.
  • @ai-sdk/react provides React hooks for AI interactions.
  • mongodb is the official MongoDB driver for Node.js.
  • zod defines typed schemas that the AI SDK uses to produce structured output.

Wait for the installation to finish. You should see a success message with the number of packages added.

Seeing permission errors during install?

Try running the command with sudo on macOS/Linux, or open your terminal as Administrator on Windows. Make sure you are inside the content-repurposer folder before running npm install.

help me fix npm permission errors when installing packages

  • Open Cursor by pressing Cmd+Space (macOS) or the Windows key (Windows), typing Cursor, and pressing Enter.
  • Click File in the top menu bar.
  • Click Open Folder.
  • Select the content-repurposer folder on your Desktop, then click Open.
Create a MongoDB Atlas account and deploy a free cluster

MongoDB Atlas is a cloud-hosted database service. The free tier gives you 512 MB of storage, which is more than enough for this project.

  • Open cloud.mongodb.com in your browser.
  • Click Register and create a new account (or sign in if you already have one).
  • After signing in, you will see the deployment screen. Select the M0 free tier.
  • Choose any cloud provider and region close to you.
  • Click Create Deployment.

What is an M0 cluster?

M0 is MongoDB's free-forever tier. It gives you 512 MB of storage and 100 operations per second. It is perfect for development and learning projects.

  • On the security screen, create a database user. Enter a username and password you will remember.
  • Write down the password somewhere safe. You will need it for the connection string.

Use a simple password

Avoid special characters like @, /, or # in your database password. These characters need URL encoding and can cause connection issues.

  • In the Network Access section, click Add IP Address.
  • Click Allow Access from Anywhere to set the IP whitelist to 0.0.0.0/0.
  • Click Confirm.

Why allow access from anywhere?

Setting the IP to 0.0.0.0/0 means any IP address can connect. This is fine for development because your database user credentials still protect access. For production apps, you would restrict this to specific IP addresses.

  • Back on the cluster overview, click Connect next to your cluster name.
  • Select Drivers as the connection method.
  • Copy the connection string. It looks like this: mongodb+srv://<username>:<password>@cluster0.xxxxx.mongodb.net/?retryWrites=true&w=majority.
  • Replace <password> in the string with the database user password you created earlier.

Can't find the Connect button?

Make sure your cluster has finished deploying. The M0 cluster takes 1-3 minutes to provision. You will see a green Active badge once it is ready.

help me find my MongoDB Atlas connection string

Configure environment variables and verify the dev server

Your app needs two secrets: the Gemini API key for AI generation and the MongoDB connection string for database access. Next.js reads these from a .env.local file that stays out of version control.

  • In Cursor, create a new file in the root of your content-repurposer folder called .env.local.
  • Add the following environment variables to .env.local:
GOOGLE_GENERATIVE_AI_API_KEY=your-api-key-here
MONGODB_URI=your-mongodb-connection-string-here
  • Replace your-api-key-here with your Google Gemini API key from your previous project.
  • Replace your-mongodb-connection-string-here with the MongoDB connection string you copied (with your password filled in).
  • Save the file.

Why .env.local?

Next.js automatically loads .env.local on startup and makes variables available through process.env. The .gitignore file created by create-next-app already excludes it from Git, so your secrets stay off GitHub.

  • Open the integrated terminal in Cursor by pressing Ctrl+` (backtick key, same on macOS and Windows).
  • Start the development server by running:
npm run dev

You should see output including Local: http://localhost:3000 in your terminal.

You should see the default Next.js welcome page. This confirms your project is scaffolded correctly and your environment variables loaded without errors.

Dev server won't start?

Check that your .env.local file is in the root of the content-repurposer folder (not inside app/ or src/). Also verify you pasted the MongoDB connection string correctly with no trailing spaces.

help me debug my Next.js dev server not starting

✔️ Awesome, I've got everything!

Great. Double check you have saved .env.local and your dev server is running at http://localhost:3000.

ⓧ I'd like to double check the full code

Here is what your .env.local file should look like (with your real values filled in):

GOOGLE_GENERATIVE_AI_API_KEY=your-api-key-here
MONGODB_URI=your-mongodb-connection-string-here

Your environment is ready. Next up, you will build the MongoDB connection utility and the AI generation route that powers the content repurposer.

Build the MongoDB Connection and AI Generation Route

Your environment is ready, your database is live, and your API key is configured. Now it's time to connect everything together into a working backend.

In this step, you'll create two files that form the core of your app. The first is a connection utility that lets your Next.js app talk to MongoDB without exhausting connections during development. The second is an API route that uses the Vercel AI SDK to generate structured, platform-specific content and save it to your database.

In this step, get ready to:
  • Create a cached MongoDB connection utility for serverless environments.
  • Build an AI generation API route that produces structured content for three platforms.
  • Test the API route to confirm everything works end-to-end.
Create the MongoDB connection utility

Next.js API routes run in a serverless-like environment. Every time you save a file during development, Next.js hot-reloads your code, which would normally create a brand new database connection each time.

Why cache the connection?

Without caching, each hot-reload during development opens a new connection to MongoDB Atlas. After a few saves, you'd hit the connection limit and see timeout errors.

The official MongoDB pattern for Next.js stores the connection promise on a global variable. This lets every API route reuse the same connection, even across hot-reloads.

  • Create a new folder called lib at the root of your project (next to app).
  • Inside lib, create a new file called mongodb.ts and paste the following code:
import { MongoClient } from 'mongodb';

// Throw an error immediately if the connection string is missing
if (!process.env.MONGODB_URI) {
  throw new Error('Please add your MongoDB URI to .env.local');
}

const uri = process.env.MONGODB_URI;
const options = {};

let client: MongoClient;
let clientPromise: Promise<MongoClient>;

// In development, store the connection on a global variable
// so it persists across hot-reloads
if (process.env.NODE_ENV === 'development') {
  const globalWithMongo = global as typeof globalThis & {
    _mongoClientPromise?: Promise<MongoClient>;
  };

  if (!globalWithMongo._mongoClientPromise) {
    client = new MongoClient(uri, options);
    globalWithMongo._mongoClientPromise = client.connect();
  }
  clientPromise = globalWithMongo._mongoClientPromise;
} else {
  // In production, create a fresh connection
  client = new MongoClient(uri, options);
  clientPromise = client.connect();
}

export default clientPromise;

What does this code do?

  • The top guard checks that MONGODB_URI exists in your environment variables and throws a helpful error if it's missing.
  • In development mode, the code attaches the connection promise to the Node.js global object so it survives hot-reloads.
  • In production, it creates a standard new connection since there are no hot-reloads.
  • The exported clientPromise resolves to a connected MongoClient that any API route can use.
  • Save lib/mongodb.ts.
  • You won't see a visible change yet. The next chunk uses this connection to talk to your database.

Seeing a red underline on the import?

  • Make sure you installed the mongodb package in Step 1. If not, run npm install mongodb in your terminal.
  • If Cursor still shows an error after installing, try restarting the TypeScript server. Press Cmd+Shift+P (macOS) or Ctrl+Shift+P (Windows), then type TypeScript: Restart TS Server.

Why is my MongoDB import not working?

✔️ Awesome, I've got everything!

Great. Double check you've saved lib/mongodb.ts before moving on.

ⓧ I'd like to double check the full code

import { MongoClient } from 'mongodb';

if (!process.env.MONGODB_URI) {
  throw new Error('Please add your MongoDB URI to .env.local');
}

const uri = process.env.MONGODB_URI;
const options = {};

let client: MongoClient;
let clientPromise: Promise<MongoClient>;

if (process.env.NODE_ENV === 'development') {
  const globalWithMongo = global as typeof globalThis & {
    _mongoClientPromise?: Promise<MongoClient>;
  };

  if (!globalWithMongo._mongoClientPromise) {
    client = new MongoClient(uri, options);
    globalWithMongo._mongoClientPromise = client.connect();
  }
  clientPromise = globalWithMongo._mongoClientPromise;
} else {
  client = new MongoClient(uri, options);
  clientPromise = client.connect();
}

export default clientPromise;
Build the AI generation API route

Now you'll create the API endpoint that does the heavy lifting. It takes raw content from the user, sends it to Gemini with a structured schema, and saves the result to MongoDB.

What is generateObject?

In your previous project, you used streamText to get free-form text responses from an AI model. generateObject is different.

It forces the AI to return structured JSON that matches a Zod schema you define. Instead of hoping the AI formats its response correctly, you guarantee it by defining the exact shape of the output up front.

  • Inside your app folder, create a new folder called api.
  • Inside api, create another folder called generate.
  • Inside generate, create a new file called route.ts.
  • Paste the following imports and schema definition into app/api/generate/route.ts:
import { generateObject } from 'ai';
import { google } from '@ai-sdk/google';
import { z } from 'zod';
import clientPromise from '@/lib/mongodb';

// Define the exact shape of the AI's response
const contentSchema = z.object({
  linkedin: z.object({
    post: z.string().describe('A professional LinkedIn post optimized for engagement'),
  }),
  twitter: z.object({
    post: z.string().describe('A concise Twitter/X post within 280 characters'),
  }),
  instagram: z.object({
    post: z.string().describe('An Instagram caption with relevant hashtags'),
  }),
});

What does this code do?

  • The imports bring in generateObject from the AI SDK, the Google provider for Gemini, Zod for schema validation, and your cached MongoDB connection.
  • The contentSchema defines three nested objects (linkedin, twitter, instagram), each containing a post string.
  • The .describe() calls give the AI context about what each field should contain. Gemini uses these descriptions to shape its output.
  • Now add the POST handler below the schema in the same file:
export async function POST(req: Request) {
  // Extract the user's content from the request body
  const { content } = await req.json();

  // Generate structured content using Gemini
  const { object } = await generateObject({
    model: google('gemini-2.5-flash'),
    schema: contentSchema,
    prompt: `You are a social media content expert. Take the following content and repurpose it into platform-optimized posts.

Rules:
- LinkedIn: Professional tone, 1-3 paragraphs, include a hook and call-to-action. Max 3000 characters.
- Twitter/X: Concise and punchy, must be under 280 characters. Use strong hooks.
- Instagram: Casual and engaging, include 3-5 relevant hashtags at the end. Max 2200 characters.

Content to repurpose:
${content}`,
  });

  // Save the generation to MongoDB
  const client = await clientPromise;
  const db = client.db('content-repurposer');
  await db.collection('generations').insertOne({
    originalContent: content,
    generated: object,
    createdAt: new Date(),
  });

  // Return the generated content to the frontend
  return Response.json(object);
}

What does this code do?

  • The POST function handles incoming requests to /api/generate.
  • It passes the user's content, your Zod schema, and the prompt rules to generateObject. Gemini returns a typed object that matches your schema exactly.
  • After generating, it connects to MongoDB using your cached client, selects the content-repurposer database and generations collection, and inserts a document.
  • MongoDB creates the database and collection automatically the first time you insert a document. You don't need to create them manually.
  • Save app/api/generate/route.ts.
  • You won't see a visible change in the browser yet. The next substep tests this route directly.

Seeing a module not found error?

  • Check that the import path is @/lib/mongodb (with the @/ prefix). This alias points to your project root and is set up automatically by Next.js.
  • Make sure you created lib/mongodb.ts at the project root, not inside the app folder.

Help me fix my module import error in route.ts.

✔️ Awesome, I've got everything!

Great. Double check you've saved app/api/generate/route.ts before moving on.

ⓧ I'd like to double check the full code

import { generateObject } from 'ai';
import { google } from '@ai-sdk/google';
import { z } from 'zod';
import clientPromise from '@/lib/mongodb';

const contentSchema = z.object({
  linkedin: z.object({
    post: z.string().describe('A professional LinkedIn post optimized for engagement'),
  }),
  twitter: z.object({
    post: z.string().describe('A concise Twitter/X post within 280 characters'),
  }),
  instagram: z.object({
    post: z.string().describe('An Instagram caption with relevant hashtags'),
  }),
});

export async function POST(req: Request) {
  const { content } = await req.json();

  const { object } = await generateObject({
    model: google('gemini-2.5-flash'),
    schema: contentSchema,
    prompt: `You are a social media content expert. Take the following content and repurpose it into platform-optimized posts.

Rules:
- LinkedIn: Professional tone, 1-3 paragraphs, include a hook and call-to-action. Max 3000 characters.
- Twitter/X: Concise and punchy, must be under 280 characters. Use strong hooks.
- Instagram: Casual and engaging, include 3-5 relevant hashtags at the end. Max 2200 characters.

Content to repurpose:
${content}`,
  });

  const client = await clientPromise;
  const db = client.db('content-repurposer');
  await db.collection('generations').insertOne({
    originalContent: content,
    generated: object,
    createdAt: new Date(),
  });

  return Response.json(object);
}
Test the API route

Your API route is built, but you haven't proven it works yet. You'll send a test request directly from the browser console to verify that Gemini returns structured content and MongoDB saves it.

  • Make sure your dev server is still running. If it stopped, run npm run dev in your terminal.
  • Open your browser to http://localhost:3000.
  • Open the browser developer console by pressing Cmd+Option+J (macOS) or Ctrl+Shift+J (Windows).
  • Test your API route by pasting this fetch call into the console and pressing Enter:
fetch('/api/generate', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ content: 'AI is transforming how developers build software. Tools like Cursor and GitHub Copilot are making coding faster and more accessible than ever.' })
}).then(r => r.json()).then(console.log)

What should I see?

After a few seconds, the console should print a JSON object with three keys: linkedin, twitter, and instagram. Each contains a post field with platform-optimized content generated by Gemini.

The response takes 3-10 seconds because Gemini is generating all three posts at once. This is normal.

  • Verify that the console output contains linkedin, twitter, and instagram fields, each with a post string inside.

Seeing a 500 error or no response?

  • Check your terminal for error messages. A common issue is an incorrect MONGODB_URI in .env.local. Make sure you replaced the <db_password> placeholder in your connection string with your actual database user password.
  • If you see an authentication error, double check that your database username and password match what you set in MongoDB Atlas.
  • If you see a network timeout, confirm your IP access list in Atlas includes 0.0.0.0/0 (which you set in Step 1).

Help me debug my /api/generate route.

Your backend is fully functional. Next up, you'll build the frontend UI so users can paste content and see their repurposed posts without touching the developer console.

Build the Content Generation UI

Your API route is live and ready to transform content into platform-optimized posts. Now you need a frontend where users can paste text and see the results.

In this step, you will build the main page of your app. It includes a text input, a generate button, and styled cards that display the AI-generated posts for LinkedIn, Twitter/X, and Instagram.

In this step, get ready to:
  • Build the main page with a textarea input form and a generate button.
  • Display AI-generated posts in styled platform cards.
  • Test the full generation flow end-to-end.
Build the main page with a form and generate button

Next.js uses app/page.tsx as the home route. This is where your entire UI lives.

The component needs to manage several pieces of state: what the user types, what the AI returns, and whether a request is in progress.

  • In Cursor, open the app folder in the sidebar.
  • Open app/page.tsx (the default file created by Next.js).
  • Delete all existing content in the file and replace it with the following code:
'use client';

import { useState, useEffect } from 'react';

// Define the shape of a generation document from MongoDB
interface Generation {
  _id: string;
  originalContent: string;
  generated: {
    linkedin: { post: string };
    twitter: { post: string };
    instagram: { post: string };
  };
  createdAt: string;
}

export default function Home() {
  // State for the input textarea
  const [content, setContent] = useState('');
  // State for the AI-generated result
  const [result, setResult] = useState<Generation['generated'] | null>(null);
  // Loading state while waiting for AI response
  const [loading, setLoading] = useState(false);
  // Array of past generations for history view
  const [history, setHistory] = useState<Generation[]>([]);
  // Toggle between history view and generate view
  const [showHistory, setShowHistory] = useState(false);

  // Fetch history on component mount
  useEffect(() => {
    fetchHistory();
  }, []);

  async function fetchHistory() {
    const res = await fetch('/api/history');
    const data = await res.json();
    setHistory(data);
  }

  async function handleGenerate() {
    if (!content.trim()) return;
    setLoading(true);
    setResult(null);

    // POST to the generate API route
    const res = await fetch('/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content }),
    });

    const data = await res.json();
    setResult(data);
    setLoading(false);
    fetchHistory();
  }

  return (
    <div className="min-h-screen bg-zinc-950 text-zinc-100">
      <header className="p-6 border-b border-zinc-800">
        <div className="max-w-4xl mx-auto flex justify-between items-center">
          <div>
            <h1 className="text-2xl font-bold">Content Repurposer</h1>
            <p className="text-sm text-zinc-400">
              Paste any content. Get optimized posts for every platform.
            </p>
          </div>
          <button
            onClick={() => setShowHistory(!showHistory)}
            className="px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm hover:bg-zinc-700"
          >
            {showHistory ? 'Hide History' : 'History'}
          </button>
        </div>
      </header>

      <main className="max-w-4xl mx-auto p-6">
        {showHistory ? (
          <div className="space-y-4">
            <h2 className="text-lg font-semibold">Recent Generations</h2>
            {history.length === 0 ? (
              <p className="text-zinc-500">No generations yet. Create your first one!</p>
            ) : (
              history.map((item) => (
                <div
                  key={item._id}
                  className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-600"
                  onClick={() => {
                    setResult(item.generated);
                    setContent(item.originalContent);
                    setShowHistory(false);
                  }}
                >
                  <p className="text-sm text-zinc-300 truncate">
                    {item.originalContent}
                  </p>
                  <p className="text-xs text-zinc-500 mt-1">
                    {new Date(item.createdAt).toLocaleDateString()}
                  </p>
                </div>
              ))
            )}
          </div>
        ) : (
          <>
            <div className="space-y-4">
              <textarea
                value={content}
                onChange={(e) => setContent(e.target.value)}
                placeholder="Paste your blog post, idea, tweet, or any content here..."
                className="w-full h-32 p-4 bg-zinc-900 border border-zinc-800 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 resize-none"
              />
              <button
                onClick={handleGenerate}
                disabled={!content.trim() || loading}
                className="px-6 py-3 bg-blue-600 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
              >
                {loading ? 'Generating...' : 'Repurpose Content'}
              </button>
            </div>
          </>
        )}
      </main>
    </div>
  );
}

What does this code do?

This is a React client component (marked with 'use client') that manages the full UI of your app.

  • Five useState hooks track the input text, AI result, loading state, history array, and whether the history panel is visible.
  • handleGenerate sends a POST request to your /api/generate route and stores the structured response in result.
  • The JSX renders a header with a History toggle, and a main area that switches between a history list and the generation form.
  • The button disables itself while loading, preventing duplicate requests.
  • Save app/page.tsx.
  • Switch to your browser and refresh http://localhost:3000. You should see a dark page with a "Content Repurposer" heading, a textarea, and a blue Repurpose Content button.

Seeing a blank page or error?

  • Check the terminal running your dev server for errors. A common issue is a typo in the interface or a missing closing bracket.
  • Make sure you deleted all the original content of page.tsx before pasting the new code.
  • Confirm you saved the file. Unsaved changes are not picked up by the dev server.

Help me debug my page.tsx - I'm seeing a blank page or compilation error after replacing the default Next.js content.

Add the platform result cards

Right now the form works, but nothing appears after you click the button. You need a results section that renders three styled cards once the AI response arrives.

  • In app/page.tsx, find this closing </div> tag (the one that wraps the textarea and button):
              </button>
            </div>
  • Add the following result cards block directly below that </div>, but still inside the <>...</> fragment:
            {result && (
              <div className="mt-8 grid gap-4 md:grid-cols-3">
                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-blue-900 text-blue-300 px-2 py-0.5 rounded">
                      LinkedIn
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.linkedin.post}
                  </p>
                </div>

                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-sky-900 text-sky-300 px-2 py-0.5 rounded">
                      Twitter/X
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.twitter.post}
                  </p>
                </div>

                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-pink-900 text-pink-300 px-2 py-0.5 rounded">
                      Instagram
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.instagram.post}
                  </p>
                </div>
              </div>
            )}

What do these cards do?

The {result && (...)} pattern only renders the cards when a result exists. Before the user clicks generate, result is null and nothing appears.

Each card uses Tailwind CSS to create a color-coded label (blue for LinkedIn, sky for Twitter/X, pink for Instagram) and whitespace-pre-wrap to preserve line breaks in the AI output.

  • Save app/page.tsx.
  • Refresh your browser. The page looks the same for now because no result has been generated yet. You will test generation in the next substep.

Seeing a syntax error after adding the cards?

  • Make sure the {result && (...)} block sits between the closing </div> of the form and the closing </> fragment.
  • Check that every opening <div> has a matching </div>. Cursor highlights mismatched brackets in red.
  • If you are unsure, use the "double check the full code" tab below to compare.

Help me fix a JSX syntax error in my page.tsx after adding the result cards section.

✔️ Awesome, I've got everything!

Great. Double check you have saved app/page.tsx before moving on.

ⓧ I'd like to double check the full code

'use client';

import { useState, useEffect } from 'react';

interface Generation {
  _id: string;
  originalContent: string;
  generated: {
    linkedin: { post: string };
    twitter: { post: string };
    instagram: { post: string };
  };
  createdAt: string;
}

export default function Home() {
  const [content, setContent] = useState('');
  const [result, setResult] = useState<Generation['generated'] | null>(null);
  const [loading, setLoading] = useState(false);
  const [history, setHistory] = useState<Generation[]>([]);
  const [showHistory, setShowHistory] = useState(false);

  useEffect(() => {
    fetchHistory();
  }, []);

  async function fetchHistory() {
    const res = await fetch('/api/history');
    const data = await res.json();
    setHistory(data);
  }

  async function handleGenerate() {
    if (!content.trim()) return;
    setLoading(true);
    setResult(null);

    const res = await fetch('/api/generate', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content }),
    });

    const data = await res.json();
    setResult(data);
    setLoading(false);
    fetchHistory();
  }

  return (
    <div className="min-h-screen bg-zinc-950 text-zinc-100">
      <header className="p-6 border-b border-zinc-800">
        <div className="max-w-4xl mx-auto flex justify-between items-center">
          <div>
            <h1 className="text-2xl font-bold">Content Repurposer</h1>
            <p className="text-sm text-zinc-400">
              Paste any content. Get optimized posts for every platform.
            </p>
          </div>
          <button
            onClick={() => setShowHistory(!showHistory)}
            className="px-4 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-sm hover:bg-zinc-700"
          >
            {showHistory ? 'Hide History' : 'History'}
          </button>
        </div>
      </header>

      <main className="max-w-4xl mx-auto p-6">
        {showHistory ? (
          <div className="space-y-4">
            <h2 className="text-lg font-semibold">Recent Generations</h2>
            {history.length === 0 ? (
              <p className="text-zinc-500">No generations yet. Create your first one!</p>
            ) : (
              history.map((item) => (
                <div
                  key={item._id}
                  className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-600"
                  onClick={() => {
                    setResult(item.generated);
                    setContent(item.originalContent);
                    setShowHistory(false);
                  }}
                >
                  <p className="text-sm text-zinc-300 truncate">
                    {item.originalContent}
                  </p>
                  <p className="text-xs text-zinc-500 mt-1">
                    {new Date(item.createdAt).toLocaleDateString()}
                  </p>
                </div>
              ))
            )}
          </div>
        ) : (
          <>
            <div className="space-y-4">
              <textarea
                value={content}
                onChange={(e) => setContent(e.target.value)}
                placeholder="Paste your blog post, idea, tweet, or any content here..."
                className="w-full h-32 p-4 bg-zinc-900 border border-zinc-800 rounded-lg text-white placeholder-zinc-500 focus:outline-none focus:border-blue-500 resize-none"
              />
              <button
                onClick={handleGenerate}
                disabled={!content.trim() || loading}
                className="px-6 py-3 bg-blue-600 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
              >
                {loading ? 'Generating...' : 'Repurpose Content'}
              </button>
            </div>

            {result && (
              <div className="mt-8 grid gap-4 md:grid-cols-3">
                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-blue-900 text-blue-300 px-2 py-0.5 rounded">
                      LinkedIn
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.linkedin.post}
                  </p>
                </div>

                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-sky-900 text-sky-300 px-2 py-0.5 rounded">
                      Twitter/X
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.twitter.post}
                  </p>
                </div>

                <div className="p-4 bg-zinc-900 border border-zinc-800 rounded-lg">
                  <div className="flex items-center gap-2 mb-3">
                    <span className="text-xs font-mono bg-pink-900 text-pink-300 px-2 py-0.5 rounded">
                      Instagram
                    </span>
                  </div>
                  <p className="text-sm text-zinc-300 whitespace-pre-wrap">
                    {result.instagram.post}
                  </p>
                </div>
              </div>
            )}
          </>
        )}
      </main>
    </div>
  );
}
Test end-to-end generation

Everything is wired up. Time to verify the full flow from input to AI-generated output.

  • Return to your browser at http://localhost:3000.
  • Paste some sample text into the textarea. For example: I just launched a side project that uses AI to summarize research papers in 30 seconds.
  • Click Repurpose Content.
  • Wait a few seconds for the AI to respond. The button text changes to "Generating..." while loading.
  • Once complete, three cards appear below the form: a blue LinkedIn card, a sky-blue Twitter/X card, and a pink Instagram card, each containing a platform-optimized version of your input.

What should I see?

Each card shows tailored content. The LinkedIn post will be longer and more professional. The Twitter/X post will be short and punchy (under 280 characters). The Instagram caption will include hashtags.

You might also see a console error about /api/history returning a 404. That is expected because you have not created the history API route yet. The generation still works.

Button stays on "Generating..." and nothing appears?

  • Open the browser DevTools console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows). Look for red error messages.
  • Check the terminal running npm run dev for server-side errors. A common issue is a missing or invalid GOOGLE_GENERATIVE_AI_API_KEY in your .env.local file.
  • If you see a MongoDB connection error, confirm your MONGODB_URI is correct and that your Atlas cluster has 0.0.0.0/0 in the IP access list.

Help me debug why my /api/generate route isn't returning results when I click the Repurpose Content button.

Your app now transforms any text into three platform-ready posts with a single click. Next up, you will build a content history feature so past generations are saved and browsable.

Add Content History

Your app generates content, but every time you refresh the page, your past generations vanish. The data is already being saved to MongoDB by your generate route. You just need a way to read it back.

In this step, you'll create an API route that retrieves your saved generations from the database. Once it's live, the history sidebar you already built in page.tsx will connect and start working automatically.

In this step, get ready to:
  • Create a history API route that reads the 20 most recent generations from MongoDB.
  • Connect the existing history sidebar to the new API endpoint.
  • Test the full flow: generate, refresh, and verify data persists.
Create the history API route

Your frontend already calls /api/history on page load and after each generation. Right now that endpoint doesn't exist, so the call fails silently. You need a GET route that queries your generations collection and returns the results as JSON.

  • Create a new file at app/api/history/route.ts and add the following code:
import clientPromise from '@/lib/mongodb';

export async function GET() {
  // Connect to MongoDB using the cached client
  const client = await clientPromise;
  const db = client.db('content-repurposer');

  // Fetch the 20 most recent generations, newest first
  const generations = await db
    .collection('generations')
    .find({})
    .sort({ createdAt: -1 })
    .limit(20)
    .toArray();

  return Response.json(generations);
}

What does this code do?

This route handles GET requests to /api/history. It reuses the same cached MongoDB connection from lib/mongodb.ts that your generate route uses.

  • .find({}) returns all documents in the collection (no filter).
  • .sort({ createdAt: -1 }) orders results by newest first. The -1 means descending order.
  • .limit(20) caps the results so you don't fetch thousands of documents as your library grows.
  • Save app/api/history/route.ts.

Seeing a module not found error?

Make sure the import path is exactly @/lib/mongodb. The @/ prefix resolves to your project root, so the file must be at lib/mongodb.ts (not inside app/).

Help me debug my history route import

Understand how the history sidebar connects

The history UI in page.tsx is already wired up to call your new API. Now that the endpoint exists, the existing code will work without any changes.

How does the history sidebar work?

Your page.tsx already has a fetchHistory function that calls /api/history and stores the result in a history state variable. It runs in two places:

  • Inside useEffect on page load, so history appears immediately.
  • At the end of handleGenerate, so the new generation shows up in history right away.

When you click a history item, its originalContent fills the textarea and its generated data populates the platform cards. The showHistory state flips back to false so you see the results.

  • Return to your browser with the dev server running.
  • Click the History button in the top-right corner of the header.

If you generated content in the previous step, you should see those generations listed. If the history is empty, that's fine. You'll generate fresh content in the next substep.

Test the full flow

The real test of a database-backed app is whether data survives a page refresh. Let's verify the full loop: generate, persist, and retrieve.

  • Click Hide History to return to the generation form.
  • Paste some text into the textarea. A short sentence or paragraph works fine.
  • Click Repurpose Content and wait for all three platform cards to appear.
  • Hard-refresh the page (Cmd+Shift+R on macOS or Ctrl+Shift+R on Windows) to clear any cached state.
  • Click History. Your previous generation should appear in the list with its original content and date.
  • Click on the history item. The textarea fills with the original content and the three platform cards reappear.

Why does history survive a refresh?

React state resets on every page load. But because your generate route saves each generation to MongoDB, the data lives in your Atlas cluster. When the page reloads, fetchHistory pulls it back from the database into state. The database is the source of truth, not the browser.

History is empty after refreshing?

Open the browser console (F12 then the Console tab) and look for errors on the /api/history request. A 404 means the file isn't saved in the right location. A 500 means the MongoDB connection might be failing.

Make sure your history route file is at exactly app/api/history/route.ts (not app/api/history.ts or pages/api/history.ts).

Help me debug why my history isn't loading

✔️ Awesome, I've got everything!

Great. Double check you've saved your file.

ⓧ I'd like to double check the full code

Here is the complete app/api/history/route.ts file:

import clientPromise from '@/lib/mongodb';

export async function GET() {
  const client = await clientPromise;
  const db = client.db('content-repurposer');
  const generations = await db
    .collection('generations')
    .find({})
    .sort({ createdAt: -1 })
    .limit(20)
    .toArray();

  return Response.json(generations);
}

Your content repurposer now has a real database-backed memory. Every generation persists across sessions, and users can browse and restore past work. Next up, you'll deploy this entire stack to the internet so anyone can use it.

Deploy to Vercel

Your content repurposer works perfectly on your machine. The final step is getting it live on the internet so you can use it from anywhere.

You will push your code to GitHub, then connect that repository to Vercel for automatic deployment. Vercel needs your environment variables to connect to MongoDB Atlas and the Gemini API in production.

In this step, get ready to:
  • Push your code to a new GitHub repository.
  • Deploy to Vercel with environment variables configured.
  • Verify the live app generates content and persists history.
Initialize a Git repo and push to GitHub

Version control lets you track changes and is required for Vercel to pull your code. You will initialize a Git repository, commit everything, then push to GitHub.

  • Initialize a new Git repository by running this command in your terminal:
git init
  • Stage and commit all your project files by running these commands:
git add .
git commit -m "Initial commit: AI content repurposer"

What about my API keys?

Your .env.local file is already listed in the .gitignore that Next.js generated when you scaffolded the project. Your secrets will not be pushed to GitHub.

  • Open GitHub in your browser and create a new repository.
  • Name it content-repurposer (or any name you prefer).
  • Leave all initialization options unchecked (no README, no .gitignore, no license).
  • Click Create repository.
  • Copy the repository URL from the Quick Setup page, then connect your local repo and push by running these commands:
git remote add origin [[REPO_URL="https://github.com/your-username/content-repurposer.git"]]
git push -u origin main
  • Refresh the GitHub page in your browser.
  • You should see all your project files listed in the repository.

Push rejected or branch not found?

If Git says the branch is master instead of main, run git branch -M main to rename it, then push again.

If you see an authentication error, make sure you are signed in to GitHub in your terminal. GitHub no longer accepts passwords for HTTPS pushes. Use a personal access token or SSH key instead.

Help me troubleshoot my git push

Import the project in Vercel and add environment variables

Vercel connects directly to your GitHub repository. Every time you push new code, Vercel rebuilds and deploys automatically.

  • Open vercel.com/dashboard in your browser.
  • Click Add New..., then select Project.
  • Find your content-repurposer repository in the list and click Import.

Don't see your repository?

If your repo does not appear, click Adjust GitHub App Permissions and grant Vercel access to the repository you just created.

  • Before clicking Deploy, expand the Environment Variables section.
  • Add your first variable: set the Name to GOOGLE_GENERATIVE_AI_API_KEY and paste your Gemini API key as the Value.
  • Add a second variable: set the Name to MONGODB_URI and paste your MongoDB connection string as the Value.
  • Click Deploy.

Vercel will build your Next.js app and deploy it. This takes about 30-60 seconds.

Build failed?

Check the build logs in the Vercel dashboard. The most common cause is a typo in your environment variable names. Make sure GOOGLE_GENERATIVE_AI_API_KEY and MONGODB_URI are spelled exactly as shown.

If you see a TypeScript error, check that all files match the code from previous steps.

Help me debug my Vercel build error

Verify the live app works

The deployment is only truly successful if you can generate content and see it persist in history at the production URL.

  • Click the production URL that Vercel shows after a successful deploy (it will look like your-project.vercel.app).
  • Paste some sample text into the textarea and click Repurpose Content.
  • Verify that LinkedIn, Twitter/X, and Instagram cards appear with generated content.
  • Click the History button in the header.
  • Confirm that your generation appears in the history list.
  • Refresh the page and check that history still shows your previous generation (proving MongoDB persistence works in production).

Generation failing in production?

If you see an error when generating content on the live site, go to your Vercel dashboard, select the project, and click Settings, then Environment Variables. Verify both values are correct and not surrounded by extra quotes or spaces.

If MongoDB is not connecting, check that your Atlas cluster's IP access list includes 0.0.0.0/0 (you set this in Step 1). Vercel's serverless functions use dynamic IPs, so the wildcard entry is required.

Help me debug my production deployment

Your AI content repurposer is live. Anyone with the link can now generate platform-optimized posts. Next up, you will tackle the Secret Mission and add tone selection with character limit warnings.

Secret mission

Add Tone Selection and Character Limits

What if you could control the AI's writing style? Add a tone selector that switches between Professional, Casual, and Witty output. Then add real-time character counts with red warnings when posts exceed platform limits.

Clean Up Your Resources

Clean Up Your Resources

This project runs entirely on free tiers, so there are no ongoing costs. Decide whether to keep your resources running, pause them to come back later, or delete them entirely.

Resources you used:

  • MongoDB Atlas free cluster (M0).
  • Vercel deployment (free tier).
  • Google Gemini API key (free tier).
  • GitHub repository.
  • Local project folder on your Desktop.

Keep everything running

No action needed. Choose this if you plan to keep using or iterating on your content repurposer.

  • MongoDB Atlas free tier (M0) has no idle charges. Your cluster will auto-pause after 30 days of zero connections, but your data is preserved.
  • Vercel free tier has no idle costs. Your deployment stays live.
  • Your Google Gemini API key remains active on the free tier.

Pause - I'll come back to this later

Free up local resources while keeping your cloud setup intact.

  • MongoDB Atlas requires no action to pause. The free cluster auto-pauses after 30 days with zero connections. Your data persists and the cluster resumes automatically when you reconnect.
  • Vercel requires no action. Your deployment stays live at no cost.
  • Stop your local dev server if it is still running by pressing Ctrl+C in the terminal.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below.

  • Delete the Vercel project: open your project in the Vercel dashboard, click Settings in the sidebar, scroll to the bottom of the General page, and click Delete in the Delete Project section. Confirm by typing the project name and clicking Continue.
  • Terminate the MongoDB Atlas cluster: open the Atlas dashboard, click Clusters under the Database heading in the sidebar, click the options menu next to your cluster, and select Terminate. Enter the cluster name to confirm, then click Terminate.
  • Delete the GitHub repository: open your repository on GitHub, click Settings, scroll down to the Danger Zone section, and click Delete this repository. Confirm by typing the repository name, then click Delete this repository.
  • Delete the local project folder by running this command:

macOS

cd ~/Desktop
rm -rf content-repurposer

Windows

cd ~\Desktop
Remove-Item -Recurse -Force content-repurposer
  • Revoke your Google Gemini API key (optional): if you no longer need the key for other projects, open Google AI Studio, navigate to your API keys, and delete the key you used for this project.

That's a Wrap!

That's a Wrap!

Nice work! You just built and deployed a fullstack AI content repurposer that transforms any text into platform-optimized posts for LinkedIn, Twitter/X, and Instagram.

You've learned how to:

  • Build structured AI output using generateObject with Zod schemas to get typed, platform-specific content from Gemini instead of raw streaming text.
  • Set up MongoDB Atlas from scratch and implement a cached connection pattern that prevents connection exhaustion in serverless environments like Next.js.
  • Deploy a fullstack app with a real database to Vercel using environment-based configuration to keep credentials secure across local and production environments.
  • Secret Mission: Added dynamic tone selection that modifies AI behavior and platform-aware character limit warnings that flag posts exceeding each platform's maximum.

Ready to quiz yourself?