Stack logo
Sync up on the latest from Convex.
Tom Redman's avatar
Tom Redman
3 months ago

Using Cursor, Claude and Convex to Build a Social Media Scheduling App

Screenshot of Cursor Composer with the text "Cursor, do my job for me"

Recently I gave myself a challenge.

I wanted to see if I could prompt my way through developing a complete full stack application using the shiny new tools available to all us: Cursor, Claude (Sonnet 3.5), and Convex. Affectionately known as the c3 stack, if only in my head.

To give myself the best chance of success, I chose to build something I already knew well: a social media scheduling application.

Functionally it's pretty straight forward. The app should be able to connect multiple social accounts, pull in historical posts and metrics, and create new posts be published at some time in the future.

To do this with AI, my mental map went something like this:

  1. prompt Cursor to scaffold an app with the stack of my choice
  2. progressively layer in lower-level functionality like social connections
  3. then layer on higher-order features, like creating a post and display analytics

Prompting Cursor to Scaffold my App: FAIL

This was my first hurdle.

It became immediately apparent that I would not be able to prompt my way through the entire process.

While the tooling we have is undeniably powerful, it's not yet capable of completing most nontrivial tasks without varying degrees of hand-holding.

First things first, I asked Cursor to scaffold my app:

We're going to develop a social media scheduling application together. 
Please set up a new project using Vite, React, Convex, Tailwind and Shadcn.

Cursor & Claude were able to produce something of a scaffolded app, however it never really stuck to the stack I had requested, nor did it use the correct commands or packages.

On the first attempt, it scaffolded a Next.js app, and then layered in (some of) the other integrations. On subsequent attempts, it created what seemed like all the permutations of the stacks I didn't ask for. But it was trying and damnit that's gotta count for something.

In all seriousness, I didn't hold this against Cursor or Claude (to the extend you can hold something against a robot who's trying help). First of all, this was the most trivial part of getting the basics of a new app up and running.

Normally, Claude and Cursor handle triviality with ease. The difference here however is the surface area involved. Trivial yes, because we have doc after doc about how to setup and configure any stack you want. But it's the surface area involved in this step that I believe was causing the two C's to trip up.

Asking Claude to cover too much ground in a single request is a receipe for trouble.

Remember that even the best and brightest LLMs have limited context windows. Not only that, but only a subset their context is "perfectly recallable". For Claude 3.5, this is between 90-95%, depending on the context length provided (source). For reference, Claude 3.5 has a context length of 200k tokens.

Make no mistake, this is very impressive. However, in the – ahem – context of programming applications, you could quickly use up 200k tokens and still be missing context.

I'm speculating, but my hunch is that Cursor is being clever about the context it's sending during requests, attempting to include all of the relevant context to the LLM without overburdening it with non-relevant information. The result is that Claude has good knowledge but not perfect knowledge. And therein lays the surface area problem.

Larger surface areas result in spottier context for Claude who will, in all fairness, do their darndest to complete the task at hand. But as their handler, it's better for us to set them up for success by providing relatively limited-scope requests.

Moving On: Building the UI with Cursor & Claude

I accepted that I'll just have to scaffold the app myself the way I want it. No problem.

$ npm create convex@latest -- -t react-vite-shadcn
$ cd my-app
$ npm i
$ npm run dev

Bingo bango bongo, we're off to the races!

With my app up and running, I wanted to drop in a full UI that I could then adapt for my use case. Typically, you'd want to design your own app, particularly if you're building a real product and making real product choices! For my purposes, effectively research, product design wasn't my primary concern.

So I grabbed the Mail app example from shadcn and I used that as my base:

At this point, a lot in my codebase has changed and I wanted to make sure Cursor was aware. So I asked it to reindex my codebase before moving forward. This feature can be found in Cursor Settings > Features > Codebase indexing.

I'm not sure how often I should do it, or even if it's necessary. But the feature is there and it gives me peace of mind, so shrug emoji.

Let's take stock of where we are at this stage:

  • I've scaffolded my app with the stack of my choice
  • I've plopped in a pre-existing app UI, courtesy of shadcn
  • I've asked Cursor to reindex my codebase

Now I'm ready to ask it for changes. And boy, did it deliver!

Hi Claude, hope you're well. Here you will find a basic web app, built with Vite, React, Convex, and Shadcn. I've used the "Mail" example for the UI, but we need to update that to reflect our app, which is a social media scheduling app.

First, update the left nav so that's it a list of social networks, with icons, and NOT the nav for a mail app. Right now you'll find it has "Inbox", "Drafts", etc. I like the layout of this design, but we need to update it to be suitable for a social media scheduling app. 

Let's update that section to be a list of social networks (with their respective icons):
- Instagram
- Facebook
- YouTube
- X
- LinkedIn

@codebase

Like a charm. With no handling, we get this result:

Feeling confident

With this nice little win under my belt, it felt like it was time to start making moves!

Great work!

Let's make some updates to the rest of the UI. 

Update @mail-list.tsx to represent a list of sent posts from the selected the social network. 

The list should include:
- the post caption
- the account it was sent from
- metrics for views and comments
- the date it was posted

This one added the right UI changes, but didn't automatically update our placeholder datasource, so one little reminder was required:

Can you update @data.tsx to match the structure you just created, and then update the imports in @mail-list.tsx to match.

One more error to fix:

"selectedNetwork" is not found in @mail-list.tsx 

Lo and behold, by golly it worked!

Ok! Let's update the preview to show the post instead of mail.

Nailed it!

Ok, let's now update @mail-display.tsx to show more details about the selected post. It should show a preview of the post - assume instagram - and the metrics for that posts. 

It should update when the selected post in @mail-list.tsx is changed, so we can navigate between our posts. 

Beep beep boop. Apply All.

Oops, one little error to fix:

I could fix this, it's pretty straight forward. But let's use Claude:

Getting this error: 

Uncaught SyntaxError: The requested module '/src/components/mail-display.tsx?t=1727704835419' does not provide an export named 'PostDisplay' (at mail.tsx:32:10)

At this point, Cursor & Claude has said it updated the file, and provided the code, but the file itself did not have the changes.

The changes didn't apply. Please try again - update @mail-display.tsx 

I tried a few ways, but ultimately had to copy and paste the changes myself.

With a little coddling, we got what we wanted:

At this point, we're about an hour in to developing our app and we have it up and running, with a UI blocked in well enough to get going with the real functionality.

Setting up your Facebook Developer Application

I have some experience with the Facebook Graph API, and so I had a good idea on how I wanted to implement the functionality. This was important background for me to be able to provide Cursor & Claude with specific steps for implementation.

When building something like this for Facebook, there are a number of steps to go through to use the Graph API.

  1. Create a Meta developer account

  2. Create a new Facebook App

    1. When prompted for type of app, choose "Other"
  3. Add 'Facebook Login for Business' as a product, create a new configuration, and select the required permissions for your use case.

    • Note: before you can make your app live, you will need to go through Facebook App Review and provide (a) specific justification for every permission and (b) a screen capture of the relevant feature in action

The Facebook developer dashboard can be tricky to navigate, but most of that is out of scope for this article. Reach out to me directly if you have any questions about navigating this particular gauntlet!

The short of it is this: to continue, we need a Facebook App ID, secret, and configuration ID with the requisite permissions. With your Facebook app in Dev mode, we can carry on.

Using Cursor to Develop with Convex

Now that we have our Facebook app setup, we can put it to use. By creating a link, you can kick off the Facebook OAuth flow:

const facebookLoginUrl = `https://www.facebook.com/20.0/dialog/oauth?client_id=<YOUR_APP_ID>&redirect_uri=https://localhost:5173&scope=email&response_type=code&state=<WHATEVER_YOU_WANT>&auth_type=rerequest&config_id=<YOUR_CONFIG_ID>`;

This will launch the Facebook login dialog and because we have specified auth_type=rerequest, it will always prompt the user to sign-in. Having set the response_type=code parameter means that on success, Facebook will redirect back to our app to the URI specified with redirect_uri with a code param in the URL. This code is short-lived and we must exchange it for a long-lived access token.

From a functional perspective, when our app loads with a code param in the URL, we need to attempt to exchange it for an access token that we'll use for further requests:

Enter, Cursor:

If the app is loaded and has a "code" search parameter in the URL, it implies that we have received an auth code from Facebook, that we need to exchange for a Facebook access token. 

Let's create this functionality using Convex. 

First, check for the code param.

If it exists, create a Convex action that exchanges the code for a token, and stores that token in the Convex database.

Huzzah! Cursor has modified our schema.ts file to support our tokens:

// ./convex/schema.ts

import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";

// The schema is entirely optional.
// You can delete this file (schema.ts) and the
// app will continue to work.
// The schema provides more precise TypeScript types.
export default defineSchema({
  facebookTokens: defineTable({
    accessToken: v.string(),
    createdAt: v.number(),
  }),
});

Not only that, but it's created a new Convex action for us to use!

// ./convex/facebook.ts

import { v } from "convex/values";
import { action } from "./_generated/server";

export const exchangeCodeForToken = action({
  args: { code: v.string() },
  handler: async (ctx, args) => {
    const { code } = args;

    // Facebook App credentials (store these securely, preferably as environment variables)
    const clientId = process.env.FACEBOOK_CLIENT_ID;
    const clientSecret = process.env.FACEBOOK_CLIENT_SECRET;
    const redirectUri = process.env.FACEBOOK_REDIRECT_URI;

    if (!clientId || !clientSecret || !redirectUri) {
      throw new Error("Facebook App credentials are not properly configured");
    }

    // Exchange code for token
    const response = await fetch(
      `https://graph.facebook.com/v12.0/oauth/access_token?client_id=${clientId}&redirect_uri=${redirectUri}&client_secret=${clientSecret}&code=${code}`,
      { method: "GET" },
    );

    if (!response.ok) {
      throw new Error("Failed to exchange code for token");
    }

    const data = await response.json();
    const accessToken = data.access_token;

    // Store the access token in the database
    await ctx.db.insert("facebookTokens", {
      accessToken,
      createdAt: new Date(),
    });

    return { success: true };
  },
});

Ok, this is decent, but it will not work as-is.

First thing that stands out is the Facebook API version is very old. v12.0 is from 2021. So, let's update that first. Hopefully Claude isn't using an API that will have breaking changes.

// Exchange code for token
    const response = await fetch(
      `https://graph.facebook.com/v20.0/oauth/access_token?client_id=${clientId}&redirect_uri=${redirectUri}&client_secret=${clientSecret}&code=${code}`,
      { method: "GET" },
    );

The rest of this line looks correct.

Worth noting here that it's my preexisting knowledge of the Graph API that allows me to spot what's wrong, if anything.

There is another issue: Convex db is not available on ctx object in an action. We need to have it run a mutation instead:

//Cursor composer chat

"db" is not available on a `ctx` object from within an Convex action, we need to create a Convex mutation to make that db write, and use "ctx.runMutation" to run the function.

@facebook.ts 

Cursor managed to fix most of the issue with this prompt:

// ./convex/facebook.ts

import { v } from "convex/values";
import { action, mutation } from "./_generated/server";

export const storeFacebookToken = mutation({
  args: { accessToken: v.string() },
  handler: async (ctx, args) => {
    const { accessToken } = args;
    await ctx.db.insert("facebookTokens", {
      accessToken,
      createdAt: new Date().getTime(),
    });
  },
});

export const exchangeCodeForToken = action({
  args: { code: v.string() },
  handler: async (ctx, args) => {
    const { code } = args;

    // Facebook App credentials (store these securely, preferably as environment variables)
    const clientId = process.env.FACEBOOK_CLIENT_ID;
    const clientSecret = process.env.FACEBOOK_CLIENT_SECRET;
    const redirectUri = process.env.FACEBOOK_REDIRECT_URI;

    if (!clientId || !clientSecret || !redirectUri) {
      throw new Error("Facebook App credentials are not properly configured");
    }

    // Exchange code for token
    const response = await fetch(
      `https://graph.facebook.com/v12.0/oauth/access_token?client_id=${clientId}&redirect_uri=${redirectUri}&client_secret=${clientSecret}&code=${code}`,
      { method: "GET" },
    );

    if (!response.ok) {
      throw new Error("Failed to exchange code for token");
    }

    const data = await response.json();
    const accessToken = data.access_token;

    // Store the access token in the database using the mutation
    await ctx.runMutation(storeFacebookToken, { accessToken });

    return { success: true };
  },
});

However, there is still an issue with this line:

await ctx.runMutation(storeFacebookToken, { accessToken });

It needs to import the generated Convex api from ./convex/_generated/api:

import { api } from "./_generated/api";
...
await ctx.runMutation(api.facebook.storeFacebookToken, { accessToken });

Finally, let's speed run collecting all the necessary data from Facebook:

  1. Once we have the access token, we request the user from the Graph API endpoint /me

    https://graph.facebook.com/v20.0/me?access_token=<TOKEN>
    
  2. From the response, we use the id to get owned accounts: /v20.0/<FB_ME_ID>/accounts/

  3. From that response, we use each account id to get any associated Instagram Business Accounts: /v20.0/<ACCOUNT_ID>?fields=instagram_business_account

  4. From that response, we use the Instagram business account ID to fetch the data for each Instagram account specifying the fields that we want:

    export const igProfileFields: string[] = [
      "id",
      "name",
      "ig_id",
      "website",
      "username",
      "biography",
      "media_count",
      "follows_count",
      "followers_count",
      "profile_picture_url",
    ];
    const profile = await axios.get(`https://graph.facebook.com/v20.0/${igBusinessAccountId}`, {
      params: { fields: igProfileFields.join(",") },
    });
    

    At this point, we can save the resulting profile to Convex. I've asked Cursor for help:

    Once we have the token, we need to request some data from Facebook:
    
    We should request:
    '/me', then use that id to get accounts, then for each account, get associated instagram business accounts, then for each of those, get their profile (id, name, website, username, media_count, etc.)
    
    Then let's save that profile to Convex.
    
    Remember, from within a Convex action , to save to Convex you need to use a mutation. To query, you need to use a query.
    
    @Codebase 
    

    Reviewing the changes to schema.ts, this looks correct:

    // ./convex/schema.ts
    
    import { defineSchema, defineTable } from "convex/server";
    import { v } from "convex/values";
    
    export default defineSchema({
      facebookTokens: defineTable({
        accessToken: v.string(),
        createdAt: v.number(),
      }),
      instagramProfiles: defineTable({
        id: v.string(),
        name: v.string(),
        website: v.optional(v.string()),
        username: v.string(),
        mediaCount: v.number(),
      }),
    });
    

    How did it fare when it comes to saving this data to Convex? My first positive signal is this little note in the terminal:

    ✔️ 11:48:47 Convex functions ready! (4.02s)
    

    This typically indicates there are no syntax errors. Yay!

    Here's the resulting convex/facebook.ts file - it looks good functionally (sans error handling):

    import { v } from "convex/values";
    import { action, mutation, query } from "./_generated/server";
    import { api } from "./_generated/api";
    
    export const storeFacebookToken = mutation({
      args: { accessToken: v.string() },
      handler: async (ctx, args) => {
        const { accessToken } = args;
        await ctx.db.insert("facebookTokens", {
          accessToken,
          createdAt: new Date().getTime(),
        });
      },
    });
    
    export const storeInstagramProfile = mutation({
      args: {
        id: v.string(),
        name: v.string(),
        website: v.optional(v.string()),
        username: v.string(),
        mediaCount: v.number(),
      },
      handler: async (ctx, args) => {
        await ctx.db.insert("instagramProfiles", args);
      },
    });
    
    export const getFacebookToken = query({
      args: {},
      handler: async (ctx) => {
        const token = await ctx.db.query("facebookTokens").order("desc").first();
        return token?.accessToken;
      },
    });
    
    export const exchangeCodeForToken = action({
      args: { code: v.string() },
      handler: async (ctx, args) => {
        const { code } = args;
    
        // Facebook App credentials (store these securely, preferably as environment variables)
        const clientId = process.env.FACEBOOK_CLIENT_ID;
        const clientSecret = process.env.FACEBOOK_CLIENT_SECRET;
        const redirectUri = process.env.FACEBOOK_REDIRECT_URI;
    
        if (!clientId || !clientSecret || !redirectUri) {
          throw new Error("Facebook App credentials are not properly configured");
        }
    
        // Exchange code for token
        const response = await fetch(
          `https://graph.facebook.com/v12.0/oauth/access_token?client_id=${clientId}&redirect_uri=${redirectUri}&client_secret=${clientSecret}&code=${code}`,
          { method: "GET" },
        );
    
        if (!response.ok) {
          throw new Error("Failed to exchange code for token");
        }
    
        const data = await response.json();
        const accessToken = data.access_token;
    
        // Store the access token in the database using the mutation
        await ctx.runMutation(api.facebook.storeFacebookToken, { accessToken });
    
        // Fetch user data
        const meResponse = await fetch(
          `https://graph.facebook.com/v12.0/me?access_token=${accessToken}`,
        );
        const meData = await meResponse.json();
    
        // Fetch accounts
        const accountsResponse = await fetch(
          `https://graph.facebook.com/v12.0/${meData.id}/accounts?access_token=${accessToken}`,
        );
        const accountsData = await accountsResponse.json();
    
        // For each account, get Instagram business accounts
        for (const account of accountsData.data) {
          const igAccountResponse = await fetch(
            `https://graph.facebook.com/v12.0/${account.id}?fields=instagram_business_account&access_token=${accessToken}`,
          );
          const igAccountData = await igAccountResponse.json();
    
          if (igAccountData.instagram_business_account) {
            const igId = igAccountData.instagram_business_account.id;
            // Fetch Instagram profile
            const igProfileResponse = await fetch(
              `https://graph.facebook.com/v12.0/${igId}?fields=id,name,website,username,media_count&access_token=${accessToken}`,
            );
            const igProfile = await igProfileResponse.json();
    
            // Store Instagram profile in Convex
            await ctx.runMutation(api.facebook.storeInstagramProfile, {
              id: igProfile.id,
              name: igProfile.name,
              website: igProfile.website,
              username: igProfile.username,
              mediaCount: igProfile.media_count,
            });
          }
        }
    
        return { success: true };
      },
    });
    
    

    We see a few of the same issues though, like the old API version. This is why we need to review everything that is generated. Even though we've previously made these corrections, it still repeats the same mistakes.

    Anyway, let's move on and fetch and save the media for displaying in our app:

    Great! 
    
    Now, let's fetch the media for each instagram page and save that to a new table in the database.
    

    Let's review the schema.ts changes:

    import { defineSchema, defineTable } from "convex/server";
    import { v } from "convex/values";
    
    export default defineSchema({
      facebookTokens: defineTable({
        accessToken: v.string(),
        createdAt: v.number(),
      }),
      instagramProfiles: defineTable({
        id: v.string(),
        name: v.string(),
        website: v.optional(v.string()),
        username: v.string(),
        mediaCount: v.number(),
      }),
      instagramMedia: defineTable({
        instagramProfileId: v.string(),
        id: v.string(),
        media_type: v.string(),
        media_url: v.string(),
        caption: v.optional(v.string()),
        timestamp: v.string(),
        like_count: v.number(),
        comments_count: v.number(),
        impressions: v.optional(v.number()),
        reach: v.optional(v.number()),
      }),
    });
    
    

    Looks good so far!

    And the code?

    import { v } from "convex/values";
    import { action, mutation, query } from "./_generated/server";
    import { api } from "./_generated/api";
    
    export const storeFacebookToken = mutation({
      args: { accessToken: v.string() },
      handler: async (ctx, args) => {
        const { accessToken } = args;
        await ctx.db.insert("facebookTokens", {
          accessToken,
          createdAt: new Date().getTime(),
        });
      },
    });
    
    export const storeInstagramProfile = mutation({
      args: {
        id: v.string(),
        name: v.string(),
        website: v.optional(v.string()),
        username: v.string(),
        mediaCount: v.number(),
      },
      handler: async (ctx, args) => {
        await ctx.db.insert("instagramProfiles", args);
      },
    });
    
    export const getFacebookToken = query({
      args: {},
      handler: async (ctx) => {
        const token = await ctx.db.query("facebookTokens").order("desc").first();
        return token?.accessToken;
      },
    });
    
    export const storeInstagramMedia = mutation({
      args: {
        instagramProfileId: v.string(),
        media: v.array(
          v.object({
            id: v.string(),
            media_type: v.string(),
            media_url: v.string(),
            caption: v.optional(v.string()),
            timestamp: v.string(),
            like_count: v.number(),
            comments_count: v.number(),
            impressions: v.optional(v.number()),
            reach: v.optional(v.number()),
          }),
        ),
      },
      handler: async (ctx, args) => {
        const { instagramProfileId, media } = args;
        for (const item of media) {
          await ctx.db.insert("instagramMedia", {
            ...item,
            instagramProfileId,
          });
        }
      },
    });
    
    export const exchangeCodeForToken = action({
      args: { code: v.string() },
      handler: async (ctx, args) => {
        const { code } = args;
    
        // Facebook App credentials (store these securely, preferably as environment variables)
        const clientId = process.env.FACEBOOK_CLIENT_ID;
        const clientSecret = process.env.FACEBOOK_CLIENT_SECRET;
        const redirectUri = process.env.FACEBOOK_REDIRECT_URI;
    
        if (!clientId || !clientSecret || !redirectUri) {
          throw new Error("Facebook App credentials are not properly configured");
        }
    
        // Exchange code for token
        const response = await fetch(
          `https://graph.facebook.com/v20.0/oauth/access_token?client_id=${clientId}&redirect_uri=${redirectUri}&client_secret=${clientSecret}&code=${code}`,
          { method: "GET" },
        );
    
        if (!response.ok) {
          throw new Error("Failed to exchange code for token");
        }
    
        const data = await response.json();
        const accessToken = data.access_token;
    
        // Store the access token in the database using the mutation
        await ctx.runMutation(api.facebook.storeFacebookToken, { accessToken });
    
        // Fetch user data
        const meResponse = await fetch(
          `https://graph.facebook.com/v20.0/me?access_token=${accessToken}`,
        );
        const meData = await meResponse.json();
    
        // Fetch accounts
        const accountsResponse = await fetch(
          `https://graph.facebook.com/v20.0/${meData.id}/accounts?access_token=${accessToken}`,
        );
        const accountsData = await accountsResponse.json();
    
        // For each account, get Instagram business accounts
        for (const account of accountsData.data) {
          const igAccountResponse = await fetch(
            `https://graph.facebook.com/v20.0/${account.id}?fields=instagram_business_account&access_token=${accessToken}`,
          );
          const igAccountData = await igAccountResponse.json();
    
          if (igAccountData.instagram_business_account) {
            const igId = igAccountData.instagram_business_account.id;
            // Fetch Instagram profile
            const igProfileResponse = await fetch(
              `https://graph.facebook.com/v20.0/${igId}?fields=id,name,website,username,media_count&access_token=${accessToken}`,
            );
            const igProfile = await igProfileResponse.json();
    
            // Store Instagram profile in Convex
            await ctx.runMutation(api.facebook.storeInstagramProfile, {
              id: igProfile.id,
              name: igProfile.name,
              website: igProfile.website,
              username: igProfile.username,
              mediaCount: igProfile.media_count,
            });
    
            // Fetch Instagram media
            const mediaResponse = await fetch(
              `https://graph.facebook.com/v20.0/${igId}/media?fields=id,media_type,media_url,caption,timestamp,like_count,comments_count,insights.metric(impressions,reach)&access_token=${accessToken}`,
            );
            const mediaData = await mediaResponse.json();
    
            const formattedMedia = mediaData.data.map((item: any) => ({
              id: item.id,
              media_type: item.media_type,
              media_url: item.media_url,
              caption: item.caption,
              timestamp: item.timestamp,
              like_count: item.like_count,
              comments_count: item.comments_count,
              impressions: item.insights?.data[0]?.values[0]?.value,
              reach: item.insights?.data[1]?.values[0]?.value,
            }));
    
            // Store Instagram media in Convex
            await ctx.runMutation(api.facebook.storeInstagramMedia, {
              instagramProfileId: igProfile.id,
              media: formattedMedia,
            });
          }
        }
    
        return { success: true };
      },
    });
    

    Amazing! It's improving and NOT repeating some mistakes! For example, its use of api. is correct. It's also sitcking to api version 20.0.

    This is next level stuff.

    Using Cursor to tie together our Convex backend with our React frontend

    Ok, we have enough to start attaching a few wires. We have a blocked out frontend, and a backend that's handling the data.

    Let's connect to our data and show some real posts in the frontend:

Please update @post-list.tsx and @post-display.tsx to show real posts from the Convex database using a Convex client query. Review @Convex docs to see how to do it.

@Codebase

(I took this opportunity to rename some of the files away from "mail" and towards "post".)

This worked beautifully. Claude & Cursor created a new function in facebook.ts for to use as a query from the client:

// ./convex/facebook.ts

export const getInstagramMedia = query({
  args: {},
  handler: async (ctx) => {
    const media = await ctx.db
      .query("instagramMedia")
      .collect();

    return media.map((post) => ({
      id: post.id,
      account: post.instagramProfileId, // We'll need to join with instagramProfiles to get the actual account name
      network: "Instagram",
      date: post.timestamp,
      caption: post.caption || "",
      views: post.impressions || 0,
      comments: post.comments_count,
      likes: post.like_count,
      shares: 0, // Instagram API doesn't provide share count
      media_url: post.media_url,
      media_type: post.media_type,
    }));
  },
});

And our frontend code for PostList.tsx looks like this:

export function PostList({ onSelectPost }: PostListProps) {
  const posts = useQuery(api.facebook.getInstagramMedia);
	// do stuff with posts

and PostDisplay.tsx is equally as exciting:

export function PostDisplay({ postId }: PostDisplayProps) {
  const posts = useQuery(api.facebook.getInstagramMedia);
  const post = posts?.find((p) => p.id === postId) || null;
  // do stuff with post

Does it work?

I'm itching to see this in action, but there are a few minor things we need to do before we can try:

  1. Give Convex the environment variables it needs to interact with the Graph API
  2. Build up our Facebook OAuth login URL
  3. ???
  4. Profit!

Filling in the blanks, our URL would be (yours will have different client and config values):

https://www.facebook.com/v20.0/dialog/oauth?client_id=2294697257538519&redirect_uri=https://localhost:5173&scope=email&response_type=code&state=501337&auth_type=rerequest&config_id=1050051203383147

Great success!

I've added the above URL to a link in our app. Let's see how it works! Here we'll go through the connection flow, and watch Convex work its magic:

What the h*ck just happened?!

Let's recap what we've done.

In about two hours, we stood up a new app. We blocked out a complex UI. We setup a Facebook app. We got our backend to interact with the Facebook Graph API, and used Convex actions, queries and mutations to ingest and store the data.

We then linked up the frontend UI with our backend datasource, using Convex's sync engine to provide live updates to our application as new data rolled in.

Two hours.

Conclusion

The combination of Cursor, Claude and Convex demonstrates a remarkable level of productivity. We built on the shoulders of giants.

However, there are a couple very important points to take away:

  • I already knew how to create an app like this, even without Claude. This was extremely helpful along the way, and I'm not sure I'd have known what to fix (or how to fix it) without this prior knowledge.
  • My knowledge allowed me to quickly spot issues and provide detailed guidance on how I wanted Claude to work.
  • No part of this codebase would be considered production ready! Honestly, it's a mess. It needs a lot of refactoring before you'd ever ship this to real people.
  • We DID get a working prototype complete in a few hours! A little handholding, sure, but the dang thing runs. Now it's really off to the races!

The extremely raw code can be found here: https://github.com/tomredman/raw-claude-project

You'll see what I mean when I say this isn't production ready! That said, feel free to clone and submit PRs - bonus points for using Cursor & Claude to improve it!

Build in minutes, scale forever.

Convex is the sync platform with everything you need to build your full-stack project. Cloud functions, a database, file storage, scheduling, search, and realtime updates fit together seamlessly.

Get started