DEV Community

Cover image for 🤯 Get visibility on X (Twitter): Schedule your posts with NextJS 🚀🚀
Eric Allam for Trigger.dev

Posted on • Originally published at trigger.dev

🤯 Get visibility on X (Twitter): Schedule your posts with NextJS 🚀🚀

TL;DR

In this tutorial, you will learn how to create an X (Twitter) post scheduler 🔥

  • Authenticates users via X (Twitter).
  • Schedule posts and save them to Supabase.
  • Post to X (Twitter) with Trigger.dev.

Image description


Your background job management for NextJS

Trigger.dev is an open-source library that enables you to create and monitor long-running jobs for your app with NextJS, Remix, Astro, and so many more!

If you can spend 10 seconds giving us a star, I would be super grateful 💖
https://github.com/triggerdotdev/trigger.dev

GiveStar


Let's set it up 🔥

Let's create a TypeScript Next.js application by running the code snippet below.

npx create-next-app x-post-scheduler
Enter fullscreen mode Exit fullscreen mode

Install the React Icons and the Headless UI packages. React Icons enable us to use different icons within the application, and we'll leverage Headless UI to add animated custom modals to the application.

npm install @headlessui/react react-icons
Enter fullscreen mode Exit fullscreen mode

Schedule


The right way for authentication 🔐

Here, I'll walk you through how to add X authentication to your Next.js application and enable permissions to send posts on behalf of your users.

To create a Twitter Developers’ project, you must have an X account. Visit the homepage and create a new project.

ScheduleTweets

Provide a suitable project name and answer all the required questions.

name your project

Next, create an App under the project and copy the tokens generated into a .env.local file within your Next.js project.

TWITTER_API_KEY=<your_api_key>
TWITTER_API_SECRET=<your_api_secret>
TWITTER_BEARER_TOKEN=<your_bearer_token>
Enter fullscreen mode Exit fullscreen mode

ScrollDownThePage

Scroll down the page and set up user authentication to enable users to sign in to your application via X.

Scroll

Select Read and write as the app permission, enable Request email from users, and select Web app as the type of app.

SelectReadWrite

Scroll down to the next section and provide your app's callback URL, website URL, and the required information. If you are using the Next.js development server, you may use the same inputs in the image below. After authentication, users are redirected to http://www.localhost:3000/dashboard, which is another route on the client side.

ScrollDownToTheNext

After setting up the authentication process, save the OAuth 2.0 Client ID and secret into the .env.local file.

TWITTER_CLIENT_ID=<app_client_id>
TWITTER_CLIENT_SECRET=<app_client_secret>
Enter fullscreen mode Exit fullscreen mode

Adding X authentication to Next.js

Create a Sign in with Twitter link element within the index.ts file that redirects users to X and allows them to grant your app access to their profile.

import { Inter } from "next/font/google";
const inter = Inter({ subsets: ["latin"] });
import Link from "next/link";

export default function Home() {
    const router = useRouter();

    const getTwitterOauthUrl = () => {
        const rootUrl = "https://twitter.com/i/oauth2/authorize";
        const options = {
            redirect_uri: "<your_callback_URL>",
            client_id: process.env.TWITTER_CLIENT_ID!,
            state: "state",
            response_type: "code",
            code_challenge: "y_SfRG4BmOES02uqWeIkIgLQAlTBggyf_G7uKT51ku8",
            code_challenge_method: "S256",
            //👇🏻 required scope for authentication and posting tweets
            scope: ["users.read", "tweet.read", "tweet.write"].join(" "),
        };
        const qs = new URLSearchParams(options).toString();
        return `${rootUrl}?${qs}`;
    };

    return (
        <main
            className={`flex items-center flex-col justify-center min-h-screen ${inter.className}`}
        >
            <h2 className='font-bold text-2xl '>X Scheduler</h2>
            <p className='mb-3 text-md'>Get started and schedule posts</p>
            <Link
                href={getTwitterOauthUrl()}
                className='bg-blue-500 py-3 px-4 text-gray-50 rounded-lg'
            >
                Sign in with Twitter
            </Link>
        </main>
    );
}
Enter fullscreen mode Exit fullscreen mode

From the code snippet above, the Sign in with Twitter link executes the getTwitterOauthUrl function, redirecting users to X and enabling them to grant the app access to their profile.

Redirect

When users authorize your app, they are redirected to the callback URL page, and you need to access the code parameter added to the URL and send this code to the server for further processing.

http://www.localhost:3000/dashboard?state=state&code=WTNhMUFYSDQwVnBsbEFoWGM0cmIwMWhKd3lJOFM1Q3FuVEdtdE5ESU1mVjIwOjE2OTY3NzMwMTEyMzc6M
Enter fullscreen mode Exit fullscreen mode

Next, create the /dashboard client route by creating a dashboard.tsx file that extracts the code parameter from the URL and sends it to the server when the component mounts.

import React, { useCallback, useEffect } from "react";

const Dashboard = () => {
    const sendAuthRequest = useCallback(async (code: string | null) => {
        try {
            const request = await fetch("/api/twitter/auth", {
                method: "POST",
                body: JSON.stringify({ code }),
                headers: {
                    "Content-Type": "application/json",
                },
            });
            const response = await request.json();
            console.log("RES >>>", response);
        } catch (err) {
            console.error(err);
        }
    }, []);

    useEffect(() => {
        const params = new URLSearchParams(window.location.href);
        const code = params.get("code");
        sendAuthRequest(code);
    }, [sendAuthRequest]);

    return (
        <main className='w-full min-h-screen'>
            <p>Dashboard</p>
        </main>
    );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

The code snippet above retrieves the code parameter from the URL and sends it to an /api/twitter/auth endpoint on the server where the user will be authenticated.

On the server, the code received fetches the user's access token. With this token, you can retrieve the user's details and save or send them to the client.

Therefore, create an api/twitter/auth.ts file (server route) that receives the code parameter from the client. Copy the code snippet below to the top of the file.

import type { NextApiRequest, NextApiResponse } from "next";

const BasicAuthToken = Buffer.from(
    `${process.env.TWITTER_CLIENT_ID!}:${process.env.TWITTER_CLIENT_SECRET!}`,
    "utf8"
).toString("base64");

const twitterOauthTokenParams = {
    client_id: process.env.TWITTER_CLIENT_ID!,
//👇🏻 according to the code_challenge provided on the client
    code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
    redirect_uri: `<your_callback_URL>`,
    grant_type: "authorization_code",
};

//gets user access token
export const fetchUserToken = async (code: string) => {
    try {
        const formatData = new URLSearchParams({
            ...twitterOauthTokenParams,
            code,
        });
        const getTokenRequest = await fetch(
            "https://api.twitter.com/2/oauth2/token",
            {
                method: "POST",
                body: formatData.toString(),
                headers: {
                    "Content-Type": "application/x-www-form-urlencoded",
                    Authorization: `Basic ${BasicAuthToken}`,
                },
            }
        );
        const getTokenResponse = await getTokenRequest.json();
        return getTokenResponse;
    } catch (err) {
        return null;
    }
};

//gets user's data from the access token
export const fetchUserData = async (accessToken: string) => {
    try {
        const getUserRequest = await fetch("https://api.twitter.com/2/users/me", {
            headers: {
                "Content-type": "application/json",
                Authorization: `Bearer ${accessToken}`,
            },
        });
        const getUserProfile = await getUserRequest.json();
        return getUserProfile;
    } catch (err) {
        return null;
    }
};
Enter fullscreen mode Exit fullscreen mode
  • From the code snippet above,
    • The BasicAuthToken variable contains the encoded version of your tokens.
    • The twitterOauthTokenParams contains the parameters required for getting the users' access token.
    • The fetchUserToken function sends a request to Twitter's endpoint and returns the user's access token, and the fetchUserData function accepts the token and retrieves the user's X profile.

Finally, create the endpoint as done below.

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    const { code } = req.body;
    try {
        const tokenResponse = await fetchUserToken(code);
        const accessToken = tokenResponse.access_token;
        if (accessToken) {
            const userDataResponse = await fetchUserData(accessToken);
            const userCredentials = { ...tokenResponse, ...userDataResponse };
            return res.status(200).json(userCredentials);
        }
    } catch (err) {
        return res.status(400).json({ err });
    }
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above retrieves the user's access token and profile details using the functions declared above, merge them into a single object, and sends them back to the client.

Congratulations! You've successfully added X (Twitter) authentication to your Next.js application.


Building the schedule dashboard ⏰

In this section, you'll learn how to create the user interface for the application by building a calendar-like table to enable users to add and delete scheduled posts within each cell.

BeforeWeProceed

Before we proceed, create a utils/util.ts file. It will contain some of the functions used within the application.

mkdir utils
cd utils
touch util.ts
Enter fullscreen mode Exit fullscreen mode

Copy the code snippet below into the util.ts file. It describes the structure of the table header and its contents (time and schedule). They will mapped into the table on the user interface.

export interface Content {
    minutes?: number;
    content?: string;
    published?: boolean;
    day?: number;
}
export interface AvailableScheduleItem {
    time: number;
    schedule: Content[][];
}
// table header
export const tableHeadings: string[] = [
    "Time",
    "Sunday",
    "Monday",
    "Tuesday",
    "Wednesday",
    "Thursday",
    "Friday",
    "Saturday",
];

// table contents
export const availableSchedule: AvailableScheduleItem[] = [
    {
        time: 0,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 1,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 2,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 3,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 4,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 5,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 6,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 7,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 8,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 9,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 10,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 11,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 12,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 13,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 14,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 15,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 16,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 17,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 18,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 19,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 20,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 21,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 22,
        schedule: [[], [], [], [], [], [], []],
    },
    {
        time: 23,
        schedule: [[], [], [], [], [], [], []],
    },
];
Enter fullscreen mode Exit fullscreen mode

Add the code snippet below into the file to help format the time into a more readable format for your users.

export const formatTime = (value: number) => {
    if (value === 0) {
        return `Midnight`;
    } else if (value < 10) {
        return `${value}am`;
    } else if (value >= 10 && value < 12) {
        return `${value}am`;
    } else if (value === 12) {
        return `${value}noon`;
    } else {
        return `${value % 12}pm`;
    }
};
Enter fullscreen mode Exit fullscreen mode

Update the dashboard.tsx file to display the table header and contents on the webpage within a table.

"use client";
import React, { useState } from "react";
import {
    Content,
    availableSchedule,
    formatTime,
    tableHeadings,
} from "@/utils/util";
import { FaClock } from "react-icons/fa6";

const Dashboard = () => {
    const [yourSchedule, updateYourSchedule] = useState(availableSchedule);

    //👇🏻 add scheduled post
    const handleAddPost = (id: number, time: number) => {
        console.log({ id, time });
    };

    //👇🏻 delete scheduled post
    const handleDeletePost = (
        e: React.MouseEvent<HTMLParagraphElement>,
        content: Content,
        time: number
    ) => {
        e.stopPropagation();
        if (content.day !== undefined) {
            console.log({ time, content });
        }
    };

    return (
        <main className='w-full min-h-screen'>
            <header className='w-full flex items-center mb-6 justify-center'>
                <h2 className='text-center font-extrabold text-3xl mr-2'>
                    Your Post Schedules
                </h2>
                <FaClock className='text-3xl text-pink-500' />
            </header>
            <div className=' p-8'>
                <div className='w-full h-[80vh] overflow-y-scroll'>
                    <table className='w-full border-collapse'>
                        <thead>
                            <tr>
                                {tableHeadings.map((day, index) => (
                                    <th
                                        key={index}
                                        className='bg-[#F8F0DF] text-lg p-4 font-bold'
                                    >
                                        {day}
                                    </th>
                                ))}
                            </tr>
                        </thead>
                        <tbody>
                            {yourSchedule.map((item, index) => (
                                <tr key={index}>
                                    <td className='bg-[#F8F0DF] text-lg font-bold'>
                                        {formatTime(item.time)}
                                    </td>
                                    {item.schedule.map((sch, id) => (
                                        <td
                                            key={id}
                                            onClick={() => handleAddPost(id, item.time)}
                                            className='cursor-pointer'
                                        >
                                            {sch.map((content, ind: number) => (
                                                <div
                                                    key={ind}
                                                    onClick={(e) =>
                                                        handleDeletePost(e, content, item.time)
                                                    }
                                                    className={`p-3 ${
                                                        content.published ? "bg-pink-500" : "bg-green-600"
                                                    }  mb-2 rounded-md text-xs cursor-pointer`}
                                                >
                                                    <p className='text-gray-700 mb-2'>
                                                        {content.minutes === 0
                                                            ? "o'clock"
                                                            : `at ${content.minutes} minutes past`}
                                                    </p>
                                                    <p className=' text-white'>{content.content}</p>
                                                </div>
                                            ))}
                                        </td>
                                    ))}
                                </tr>
                            ))}
                        </tbody>
                    </table>
                </div>
            </div>
        </main>
    );
};

export default Dashboard;
Enter fullscreen mode Exit fullscreen mode

The handleAddPost function runs when the user clicks on an empty cell on the table, and the handleDeletePost is executed when the user clicks on a scheduled post within a cell.

Within the util.ts file, create the Typescript interface for newly added posts and selected posts to be deleted. The DelSectedCell Typescript interface defines the properties of a post within a cell, and the SelectedCell represents the structure of the newly added post.

export interface DelSelectedCell {
    content?: string;
    day_id?: number;
    day?: string;
    time_id?: number;
    time?: string;
    minutes?: number;
}
export interface SelectedCell {
    day_id?: number;
    day?: string;
    time_id?: number;
    time?: string;
    minutes?: number;
}
Enter fullscreen mode Exit fullscreen mode

Import the DelSelectedCell and SelectedCell interfaces into the dashboard.tsx file and create the states holding both data structures. Add two boolean states that display the needed modal when the user performs an add or delete event.

const [selectedCell, setSelectedCell] = useState<SelectedCell>({
    day_id: 0,
    day: "",
    time_id: 0,
    time: "",
});
const [delSelectedCell, setDelSelectedCell] = useState<DelSelectedCell>({
    content: "",
    day_id: 0,
    day: "",
    time_id: 0,
    time: "",
    minutes: 0,
});
//👇🏻 triggers the add post modal
const [addPostModal, setAddPostModal] = useState(false);
//👇🏻 triggers the delete post modal
const [deletePostModal, setDeletePostModal] = useState(false);
Enter fullscreen mode Exit fullscreen mode

Modify the handleAddPost and handleDeletePost functions to update the recently added states as follows.

const handleAddPost = (id: number, time: number) => {
    setSelectedCell({
        day_id: id + 1,
        day: tableHeadings[id + 1],
        time_id: time,
        time: formatTime(time),
    });
    setAddPostModal(true);
};

const handleDeletePost = (
    e: React.MouseEvent<HTMLParagraphElement>,
    content: Content,
    time: number
) => {
    e.stopPropagation();
    if (content.day !== undefined) {
        setDelSelectedCell({
            content: content.content,
            day_id: content.day,
            day: tableHeadings[content.day],
            time_id: time,
            time: formatTime(time),
            minutes: content.minutes,
        });
        setDeletePostModal(true);
    }
};
Enter fullscreen mode Exit fullscreen mode

The Delete and Add post modals with Headless UI

In this section, how to add posts when a user clicks on a cell and delete posts from the schedule when users click on a specific post in a cell.

Create a components folder containing the add and delete post modals.

mkdir components
cd components
touch AddPostModal.tsx DeletePostModal.tsx
Enter fullscreen mode Exit fullscreen mode

Display both components within the dashboard.tsx when the user wants to add or delete a post.

return (
    <main>
        {/*-- other dashboard elements --*/}
        {addPostModal && (
            <AddPostModal
                setAddPostModal={setAddPostModal}
                addPostModal={addPostModal}
                selectedCell={selectedCell}
                yourSchedule={yourSchedule}
                updateYourSchedule={updateYourSchedule}
                profile={username}
            />
        )}
        {deletePostModal && (
            <DeletePostModal
                setDeletePostModal={setDeletePostModal}
                deletePostModal={deletePostModal}
                delSelectedCell={delSelectedCell}
                yourSchedule={yourSchedule}
                updateYourSchedule={updateYourSchedule}
                profile={username}
            />
        )}
    </main>
);
Enter fullscreen mode Exit fullscreen mode

Both components accept the schedule, the states containing the post to be added or deleted, and the user's X username as props.

Copy the code snippet below into the AddPostModal.tsx file.

import {
    SelectedCell,
    AvailableScheduleItem,
    updateSchedule,
} from "@/utils/util";
import { Dialog, Transition } from "@headlessui/react";
import {
    FormEventHandler,
    Fragment,
    useState,
    Dispatch,
    SetStateAction,
} from "react";

interface Props {
    setAddPostModal: Dispatch<SetStateAction<boolean>>;
    updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
    addPostModal: boolean;
    selectedCell: SelectedCell;
    profile: string | any;
    yourSchedule: AvailableScheduleItem[];
}

const AddPostModal: React.FC<Props> = ({
    setAddPostModal,
    addPostModal,
    selectedCell,
    updateYourSchedule,
    profile,
    yourSchedule,
}) => {
    const [content, setContent] = useState<string>("");
    const [minute, setMinute] = useState<number>(0);
    const closeModal = () => setAddPostModal(false);

const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
        e.preventDefault();
}
    return ()
}
Enter fullscreen mode Exit fullscreen mode

The code snippet above accepts the values passed via props into the component. The content and minute states hold the post's content and the post's scheduled minute. The handleSubmit function is executed when the user clicks the Save button to schedule a post.

Return the JSX elements below from the AddPostModal component. The code snippet below uses the Headless UI components to render an animated and already customised modal.

<div>
    <Transition appear show={addPostModal} as={Fragment}>
        <Dialog as='div' className='relative z-10' onClose={closeModal}>
            <Transition.Child
                as={Fragment}
                enter='ease-out duration-300'
                enterFrom='opacity-0'
                enterTo='opacity-100'
                leave='ease-in duration-200'
                leaveFrom='opacity-100'
                leaveTo='opacity-0'
            >
                <div className='fixed inset-0 bg-black bg-opacity-80' />
            </Transition.Child>

            <div className='fixed inset-0 overflow-y-auto'>
                <div className='flex min-h-full items-center justify-center p-4 text-center'>
                    <Transition.Child
                        as={Fragment}
                        enter='ease-out duration-300'
                        enterFrom='opacity-0 scale-95'
                        enterTo='opacity-100 scale-100'
                        leave='ease-in duration-200'
                        leaveFrom='opacity-100 scale-100'
                        leaveTo='opacity-0 scale-95'
                    >
                        <Dialog.Panel className='w-full max-w-xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all'>
                            <Dialog.Title
                                as='h3'
                                className='text-xl font-bold leading-6 text-gray-900'
                            >
                                Schedule a post on {selectedCell.day} by {selectedCell.time}
                            </Dialog.Title>

                            <form className='mt-2' onSubmit={handleSubmit}>
                                {minute > 59 && (
                                    <p className='text-red-600'>
                                        Error, please minute must be less than 60
                                    </p>
                                )}
                                <label htmlFor='minute' className='opacity-60'>
                                    How many minutes past?
                                </label>
                                <input
                                    type='number'
                                    className='w-full border-[1px] px-4 py-2 rounded-md mb-2'
                                    name='title'
                                    id='title'
                                    value={minute.toString()}
                                    onChange={(e) => setMinute(parseInt(e.target.value, 10))}
                                    max={59}
                                    required
                                />

                                <label htmlFor='content' className='opacity-60'>
                                    Post content
                                </label>
                                <textarea
                                    className='w-full border-[1px] px-4 py-2 rounded-md mb-2 text-sm'
                                    name='content'
                                    id='content'
                                    value={content}
                                    onChange={(e) => setContent(e.target.value)}
                                    required
                                />

                                <div className='mt-4 flex items-center justify-between space-x-4'>
                                    <button
                                        type='submit'
                                        className='inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
                                    >
                                        Save
                                    </button>

                                    <button
                                        type='button'
                                        className='inline-flex justify-center rounded-md border border-transparent bg-red-100 px-4 py-2 text-sm font-medium text-red-900 hover:bg-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2'
                                        onClick={closeModal}
                                    >
                                        Cancel
                                    </button>
                                </div>
                            </form>
                        </Dialog.Panel>
                    </Transition.Child>
                </div>
            </div>
        </Dialog>
    </Transition>
</div>
Enter fullscreen mode Exit fullscreen mode

AsDownBelow

Update the handleSubmit function as done below.

const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
    e.preventDefault();
    if (
        Number(minute) < 60 &&
        content.trim().length > 0 &&
        selectedCell.time_id !== undefined &&
        selectedCell.day_id !== undefined
    ) {
        const newSchedule = [...yourSchedule];
        const selectedDay =
            newSchedule[selectedCell.time_id].schedule[selectedCell.day_id - 1];
        selectedDay.push({
            content,
            published: false,
            minutes: minute,
            day: selectedCell.day_id,
        });
        updateYourSchedule(newSchedule);
        closeModal();
    }
};
Enter fullscreen mode Exit fullscreen mode

The function first validates if the minute entered by the user is valid, ensures the content is not empty, and confirms that the selected time and day are defined. Then, it retrieves the user's post schedule, identifies the selected day scheduled for the post, and updates the array with the new post details.

ValidateEntry

Add the code snippet below into the DeletePostModal.tsx file.

"use client";
import { Dialog, Transition } from "@headlessui/react";
import { Fragment, Dispatch, SetStateAction } from "react";
import {
    DelSelectedCell,
    AvailableScheduleItem,
    updateSchedule,
} from "../utils/util";

interface Props {
    setDeletePostModal: Dispatch<SetStateAction<boolean>>;
    deletePostModal: boolean;
    delSelectedCell: DelSelectedCell;
    profile: string | any;
    yourSchedule: AvailableScheduleItem[];
    updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
}

const DeletePostModal: React.FC<Props> = ({
    setDeletePostModal,
    deletePostModal,
    delSelectedCell,
    yourSchedule,
    updateYourSchedule,
    profile,
}) => {
    const closeModal = () => setDeletePostModal(false);

    const handleDelete = () => {};

    return <main>{/**-- JSX elements --**/}</main>;
};
Enter fullscreen mode Exit fullscreen mode

Render these JSX elements within the DeletePostModal component.

return (
    <div>
        <Transition appear show={deletePostModal} as={Fragment}>
            <Dialog as='div' className='relative z-10' onClose={closeModal}>
                <Transition.Child
                    as={Fragment}
                    enter='ease-out duration-300'
                    enterFrom='opacity-0'
                    enterTo='opacity-100'
                    leave='ease-in duration-200'
                    leaveFrom='opacity-100'
                    leaveTo='opacity-0'
                >
                    <div className='fixed inset-0 bg-black bg-opacity-25' />
                </Transition.Child>

                <div className='fixed inset-0 overflow-y-auto'>
                    <div className='flex min-h-full items-center justify-center p-4 text-center'>
                        <Transition.Child
                            as={Fragment}
                            enter='ease-out duration-300'
                            enterFrom='opacity-0 scale-95'
                            enterTo='opacity-100 scale-100'
                            leave='ease-in duration-200'
                            leaveFrom='opacity-100 scale-100'
                            leaveTo='opacity-0 scale-95'
                        >
                            <Dialog.Panel className='w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all'>
                                <Dialog.Title
                                    as='h3'
                                    className='text-xl font-bold leading-6 text-gray-900'
                                >
                                    Delete post
                                </Dialog.Title>
                                <div className='mt-2'>
                                    <p className='mb-3'>Are you sure you want to delete?</p>
                                    <p className='text-sm text-gray-500'>
                                        {`"${delSelectedCell.content}" scheduled for ${delSelectedCell.day} at ${delSelectedCell.time_id}:${delSelectedCell.minutes}`}
                                    </p>
                                </div>

                                <div className='mt-4 flex items-center justify-between space-x-4'>
                                    <button
                                        type='button'
                                        className='inline-flex justify-center rounded-md border border-transparent bg-blue-100 px-4 py-2 text-sm font-medium text-blue-900 hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2'
                                        onClick={handleDelete}
                                    >
                                        Yes
                                    </button>
                                    <button
                                        type='button'
                                        className='inline-flex justify-center rounded-md border border-transparent bg-red-100 px-4 py-2 text-sm font-medium text-red-900 hover:bg-red-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500 focus-visible:ring-offset-2'
                                        onClick={closeModal}
                                    >
                                        Cancel
                                    </button>
                                </div>
                            </Dialog.Panel>
                        </Transition.Child>
                    </div>
                </div>
            </Dialog>
        </Transition>
    </div>
);
Enter fullscreen mode Exit fullscreen mode

Modify the handleDelete function to accept the properties of the selected post and remove it from the schedule.

const handleDelete = () => {
    if (
        delSelectedCell.time_id !== undefined &&
        delSelectedCell.day_id !== undefined
    ) {
        //👇🏻 gets the user's post schedule
        const initialSchedule = [...yourSchedule];
        //👇🏻 gets the exact day the post is scheduled for
        let selectedDay =
            initialSchedule[delSelectedCell.time_id].schedule[
                delSelectedCell.day_id - 1
            ];
        //👇🏻 filters the array to remove the selected post
        const updatedPosts = selectedDay.filter(
            (day) =>
                day.content !== delSelectedCell.content &&
                day.minutes !== delSelectedCell.minutes
        );
        //👇🏻 updates the schedule
        initialSchedule[delSelectedCell.time_id].schedule[
            delSelectedCell.day_id - 1
        ] = updatedPosts;
        //👇🏻 updates the schedule
        updateYourSchedule(initialSchedule);
        closeModal();
    }
};
Enter fullscreen mode Exit fullscreen mode

Congratulations

Congratulations on making it thus far! You can now add and remove scheduled posts from each cell. Next, let's connect a backend database (Supabase) to the application to persist data when the page is refreshed.


Saving everything to the database 📀

Supabase is an open-source Firebase alternative that enables you to add authentication, file storage, Postgres, and real-time database to your software applications. With Supabase, you can build secured and scalable applications in a few minutes.

In this section, you'll learn how to integrate Supabase into your Next.js application and save and update the user's schedule via Supabase. Before we proceed, install the Supabase package.

npm install @supabase/supabase-js
Enter fullscreen mode Exit fullscreen mode

Visit the Supabase homepage and create a new organization and project.

Supa1

To set up the database for the application, you need to create two tables - schedule_posts containing the scheduled posts and the users table containing all the user's information retrieved from X.

The users table has three columns containing the username, ID, and access token retrieved after authentication. Ensure you make the username column the primary key for the table.

schedule_posts

The schedule_posts table contains six columns: a unique ID for each row, the timestamp indicating when the post will be live, the post content for X, the post status, the user's username on X, and day_id representing the scheduled day. day_id is necessary for retrieving existing schedules.

foreignkey

Next, make the profile column a foreign key to the username column on the users table. We'll need this connection when making queries that merge the data within both tables.

SideBar

Click API on the sidebar menu and copy the project's URL and API into the .env.local file.

NEXT_PUBLIC_SUPABASE_URL=<public_supabase_URL>
NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase_anon_key>
Enter fullscreen mode Exit fullscreen mode

SupabaseApi

Finally, create a src/supbaseClient.ts file and create the Supabase client for the application.

import { createClient } from "@supabase/supabase-js";

const supabaseURL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;

export const supabase = createClient(supabaseURL, supabaseAnonKey, {
  auth: { persistSession: false },
});
Enter fullscreen mode Exit fullscreen mode

Finally, create an api/schedule folder on the server containing a read, create, and delete route. The api/schedule/create adds a new post schedule to the database, api/schedule/delete deletes a selected post, and api/schedule/read fetches all the posts created by a user.

cd pages/api
mkdir schedule
touch create.ts delete.ts read.ts
Enter fullscreen mode Exit fullscreen mode

Before we continue, update the api/twitter/auth endpoint to save the user’s access token and profile information to Supabase after authentication.

import { supabase } from '../../../../supabaseClient';

export default async function handler(
  req: NextApiRequest,
  res: NextApiResponse
) {
    const { code } = req.body
        try { 
            const tokenResponse = await fetchUserToken(code)
            const accessToken = tokenResponse.access_token

            if (accessToken) {
                const userDataResponse = await fetchUserData(accessToken)
                const userCredentials = { ...tokenResponse, ...userDataResponse };
                                //👇🏻 saves user's information to Supabase
                const { data, error } = await supabase.from("users").insert({
                    id: userCredentials.data.id,
                    accessToken: userCredentials.access_token,
                    username: userCredentials.data.username
                });
                if (!error) {
                    return res.status(200).json(userCredentials)

                } else {c
                    return res.status(400).json({error})
                }         
            }

        } catch (err) {
            return res.status(400).json({err})
        }
}
Enter fullscreen mode Exit fullscreen mode

Scheduling new posts

Modify the api/schedule/create endpoint to accept the post details and save them to the Supabase.

import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    const { profile, timestamp, content, published, day_id } = req.body;

    const { data, error } = await supabase.from("schedule_posts").insert({
        profile, timestamp, content, published, day_id
    });
    res.status(200).json({data, error});

}
Enter fullscreen mode Exit fullscreen mode

Add a function within the utils/util.ts file that accepts all the post details from the client and sends them to the endpoint. Execute the function when a user adds a new schedule.

export const getNextDayOfWeek = (dayOfWeek: number, hours: number, minutes: number) => {
    var today = new Date();
    var daysUntilNextDay = dayOfWeek - today.getDay();
    if (daysUntilNextDay < 0) {
        daysUntilNextDay += 7;
    }
    today.setDate(today.getDate() + daysUntilNextDay);
    today.setHours(hours);
    today.setMinutes(minutes);
    return today;
}

export const updateSchedule = async (
    profile: string,
    schedule: any
) => {
    const { day_id, time, minutes, content, published } = schedule
    const timestampFormat = getNextDayOfWeek(day_id, time, minutes)
    try {
    await fetch("/api/schedule/create", {
            method: "POST",
            body: JSON.stringify({profile, timestamp: timestampFormat, content, published, day_id}),
            headers: {
                "Content-Type": "application/json",
            },
        });

    } catch (err) {
        console.error(err);
    }
};
Enter fullscreen mode Exit fullscreen mode

The getNextDayOfWeek function accepts the day, hour, and minutes of the post, gets the date of the selected day, and converts the post's time and date into a datetime format - the acceptable format on the database, before adding it to the request's body. The profile parameter contains the user’s X username stored in local storage after authentication.

Deleting scheduled posts

Update the api/schedule/delete endpoint to accept the post’s data and delete it from database.

import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    const { profile, timestamp, content } = req.body;

    const { data, error } = await supabase
        .from("schedule_posts").delete().eq("content", content).eq("timestamp", timestamp.toISOString())

    res.status(200).json({ data, error });
}
Enter fullscreen mode Exit fullscreen mode

Create the client-side function within the utils/util.ts file that sends a request to the endpoint when the user deletes a post.

export const deleteSchedule = async (
    profile: string,
    schedule: any
) => {
    const { day_id, time_id, minutes, content, published } = schedule
    const timestampFormat = getNextDayOfWeek(day_id, time_id, minutes)

    try {
        await fetch("/api/schedule/delete", {
            method: "POST",
            body: JSON.stringify({ profile, timestamp: timestampFormat,  content}),
            headers: {
                "Content-Type": "application/json",
            },
        });
    } catch (err) {
        console.error(err);
    }

};
Enter fullscreen mode Exit fullscreen mode

Fetching users’ schedule

Modify the api/schedule/create endpoint to accept a post details and add them to the database.

// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
import type { NextApiRequest, NextApiResponse } from "next";
import { supabase } from "../../../../supabaseClient";

export default async function handler(
    req: NextApiRequest,
    res: NextApiResponse
) {
    const { profile, timestamp, content, published, day_id } = req.body;

    const { data, error } = await supabase.from("schedule_posts").insert({
        profile, timestamp, content, published, day_id
    });
    res.status(200).json({data, error});

}
Enter fullscreen mode Exit fullscreen mode

Create its request function on the client that retrieves all the scheduled posts and converts them to the format required on the front end.

export const fetchSchedule = async (
    profile: string, updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>
) => {
    try {
        const request = await fetch("/api/schedule/read", {
            method: "POST",
            body: JSON.stringify({ profile }),
            headers: {
                "Content-Type": "application/json",
            },
        });
        const response = await request.json();
        const { data } = response
        //👇🏻 if there is a response
        if (data) {
            const result = data.map((item: any) => {
                const date = new Date(item.timestamp);
                //👇🏻 returns a new array in the format below
                return {
                    //👇🏻 converts 24 hour to 0 hour
                    time: date.getUTCHours() + 1 < 24 ? date.getUTCHours() + 1 : 0,
                    schedule: {
                        content: item.content,
                        published: item.published,
                        minutes: date.getUTCMinutes(),
                        day: item.day_id
                    },
                }

            })
            //👇🏻 loops through retrieved schedule and add them to the large table array 
            result.forEach((object: any) => {
                const matchingObjIndex = availableSchedule.findIndex((largeObj) => largeObj.time === object.time);
                if (matchingObjIndex !== -1) {
                    availableSchedule[matchingObjIndex].schedule[object.schedule.day].push(object.schedule)
                }
            })
            updateYourSchedule(availableSchedule)
        }
    } catch (err) {
        console.error(err);
    }

};
Enter fullscreen mode Exit fullscreen mode

The code snippet above receives the scheduled posts from the server, converts it into the format required by the table UI, then loops through the data, adds them to the large array - availableSchedule that contains the table layout, and updates the schedule state with the modified version.

Congratulations! You've successfully added Supabase to the application.


Sending the post at the right time ⏳

Trigger.dev offers three communication methods: webhook, schedule, and event. Schedule is ideal for recurring tasks, events activate a job upon sending a payload, and webhooks trigger real-time jobs when specific events occur.

In this section, you’ll learn how to schedule recurring tasks with Trigger.dev by comparing the current time with the time scheduled post times and automatically posting them to X at their scheduled times.

Adding Trigger.dev to a Next.js app

Before we continue, you need to create a Trigger.dev account. Once registered, create an organization and choose a project name for your jobs.

TriggerAccount

Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.

NextTrigger

Otherwise, click Environments & API Keys on the sidebar menu of your project dashboard.

Environments

Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.

npx @trigger.dev/cli@latest init
Enter fullscreen mode Exit fullscreen mode

Start your Next.js project.

npm run dev
Enter fullscreen mode Exit fullscreen mode

In another terminal, run the following code snippet to establish a tunnel between Trigger.dev and your Next.js project.

npx @trigger.dev/cli@latest dev
Enter fullscreen mode Exit fullscreen mode

Finally, rename the jobs/examples.ts file to jobs/functions.ts. This is where all the jobs are processed.

Congratulations!🎉 You've successfully added Trigger.dev to your Next.js app.

Posting scheduled contents on X with Trigger.dev

Here, you'll learn how to create recurring jobs with Trigger. The job will check the scheduled posts every minute and post content to X at the exact time stated by the user.

To integrate Trigger.dev with Supabase, you need to install the Trigger.dev Supabase package.

npm install @trigger.dev/supabase
Enter fullscreen mode Exit fullscreen mode

Import the [cronTrigger](https://trigger.dev/docs/sdk/crontrigger) and Supabase from their packages into the jobs/functions.ts file. The cronTrigger() function enables us to execute jobs on a recurring schedule.

PS: Scheduled Triggers do not trigger Jobs in the DEV Environment. When you’re working locally you should use the Test feature to trigger any scheduled jobs.

import { cronTrigger } from "@trigger.dev/sdk";
import { Supabase } from "@trigger.dev/supabase";

const supabase = new Supabase({
    id: "supabase",
    supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
    supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
});
Enter fullscreen mode Exit fullscreen mode

Modify the job within the jobs/functions.ts file to fetch posts that are yet to be published, and whose schedule time matches the current time.

client.defineJob({
    id: "post-schedule",
    name: "Post Schedule",
    //👇🏻 integrate Supabase
    integrations: { supabase },
    version: "0.0.1",
    //👇🏻 runs every minute
    trigger: cronTrigger({
      cron: "* * * * *",
    }),
    run: async (payload, io, ctx) => {
        await io.logger.info("Job started! 🌟");

        const { data, error } = await io.supabase.runTask(
            "find-schedule",
            async (db) => {
                return (await db.from("schedule_posts")
                    .select(`*, users (username, accessToken)`)
                    .eq("published", false)
                    .lt("timestamp", new Date().toISOString()))
            }
        );

        await io.logger.info(JSON.stringify(data))
}
});
Enter fullscreen mode Exit fullscreen mode

The code snippet above uses the foreign key (username) created between the users and the schedule_posts table to perform a join query. This query returns the access token and username from the users table and all the data within schedule_posts.

Iterate

Finally, iterate through the posts and post its content on X.

      for (let i = 0; i < data?.length; i++) {
            try {
                const postTweet = await fetch("https://api.twitter.com/2/tweets", {
                    method: "POST",
                    headers: {
                        "Content-type": "application/json",
                        Authorization: `Bearer ${data[i].users.accessToken}`,
                    },
                    body: JSON.stringify({ text: data[i].content })
                })
                const getResponse = await postTweet.json()
                await io.logger.info(`${i}`)
                await io.logger.info(`Tweet created successfully!${i} - ${getResponse.data}`)

            } catch (err) {
                await io.logger.error(err)
            }

        }
Enter fullscreen mode Exit fullscreen mode

Congratulations! You have completed the project for this tutorial.

Conclusion


Conclusion

So far, you've learned how to add Twitter (X) authentication to a Next.js application, save data to Supabase, and create recurring tasks with Trigger.dev.

As an open-source developer, you're invited to join our community to contribute and engage with maintainers. Don't hesitate to visit our GitHub repository to contribute and create issues related to Trigger.dev.

The source for this tutorial is available here:

https://github.com/triggerdotdev/blog/tree/main/x-post-scheduler

Thank you for reading!


Help me out 🩷

If you can spend 10 seconds giving us a star, I would be super grateful 💖
https://github.com/triggerdotdev/trigger.dev

GiveStar

Top comments (7)

Collapse
 
srbhr profile image
Saurabh Rai • Edited

Interesting post. What did you use to create the GIF of the dashboard (at the end of the post)?

Collapse
 
bugb profile image
bugb

it seems an AI-generated image

Collapse
 
merri profile image
Vesa Piittinen

A nice coding practise for people to do! There's just one problem. Twitter isn't worth supporting anymore.

Collapse
 
avinashvagh profile image
Avinash Vagh

Seems interesting, Need to execute it.

Collapse
 
nathan_tarbert profile image
Nathan Tarbert

This is great, something I need to implement so I can schedule posts on X. Thanks!

Collapse
 
mgnblck profile image
James Cathedral

Can we get the full code for this?

Collapse
 
nevodavid profile image
Nevo David • Edited

This is a really nice way to get more engagement over X!