Article

·

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

Eric Allam

Eric Allam

CTO, Trigger.dev

Image for 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.


_10
npx create-next-app x-post-scheduler

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.


_10
npm install @headlessui/react react-icons

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.


_10
TWITTER_API_KEY=<your_api_key>
_10
TWITTER_API_SECRET=<your_api_secret>
_10
TWITTER_BEARER_TOKEN=<your_bearer_token>

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.


_10
TWITTER_CLIENT_ID=<app_client_id>
_10
TWITTER_CLIENT_SECRET=<app_client_secret>

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.


_38
import { Inter } from "next/font/google";
_38
const inter = Inter({ subsets: ["latin"] });
_38
import Link from "next/link";
_38
_38
export default function Home() {
_38
const router = useRouter();
_38
_38
const getTwitterOauthUrl = () => {
_38
const rootUrl = "https://twitter.com/i/oauth2/authorize";
_38
const options = {
_38
redirect_uri: "<your_callback_URL>",
_38
client_id: process.env.TWITTER_CLIENT_ID!,
_38
state: "state",
_38
response_type: "code",
_38
code_challenge: "y_SfRG4BmOES02uqWeIkIgLQAlTBggyf_G7uKT51ku8",
_38
code_challenge_method: "S256",
_38
//👇🏻 required scope for authentication and posting tweets
_38
scope: ["users.read", "tweet.read", "tweet.write"].join(" "),
_38
};
_38
const qs = new URLSearchParams(options).toString();
_38
return `${rootUrl}?${qs}`;
_38
};
_38
_38
return (
_38
<main
_38
className={`flex min-h-screen flex-col items-center justify-center ${inter.className}`}
_38
>
_38
<h2 className="text-2xl font-bold ">X Scheduler</h2>
_38
<p className="text-md mb-3">Get started and schedule posts</p>
_38
<Link
_38
href={getTwitterOauthUrl()}
_38
className="rounded-lg bg-blue-500 px-4 py-3 text-gray-50"
_38
>
_38
Sign in with Twitter
_38
</Link>
_38
</main>
_38
);
_38
}

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.


_10
http://www.localhost:3000/dashboard?state=state&code=WTNhMUFYSDQwVnBsbEFoWGM0cmIwMWhKd3lJOFM1Q3FuVEdtdE5ESU1mVjIwOjE2OTY3NzMwMTEyMzc6M

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.


_33
import React, { useCallback, useEffect } from "react";
_33
_33
const Dashboard = () => {
_33
const sendAuthRequest = useCallback(async (code: string | null) => {
_33
try {
_33
const request = await fetch("/api/twitter/auth", {
_33
method: "POST",
_33
body: JSON.stringify({ code }),
_33
headers: {
_33
"Content-Type": "application/json",
_33
},
_33
});
_33
const response = await request.json();
_33
console.log("RES >>>", response);
_33
} catch (err) {
_33
console.error(err);
_33
}
_33
}, []);
_33
_33
useEffect(() => {
_33
const params = new URLSearchParams(window.location.href);
_33
const code = params.get("code");
_33
sendAuthRequest(code);
_33
}, [sendAuthRequest]);
_33
_33
return (
_33
<main className="min-h-screen w-full">
_33
<p>Dashboard</p>
_33
</main>
_33
);
_33
};
_33
_33
export default Dashboard;

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.


_55
import type { NextApiRequest, NextApiResponse } from "next";
_55
_55
const BasicAuthToken = Buffer.from(
_55
`${process.env.TWITTER_CLIENT_ID!}:${process.env.TWITTER_CLIENT_SECRET!}`,
_55
"utf8"
_55
).toString("base64");
_55
_55
const twitterOauthTokenParams = {
_55
client_id: process.env.TWITTER_CLIENT_ID!,
_55
//👇🏻 according to the code_challenge provided on the client
_55
code_verifier: "8KxxO-RPl0bLSxX5AWwgdiFbMnry_VOKzFeIlVA7NoA",
_55
redirect_uri: `<your_callback_URL>`,
_55
grant_type: "authorization_code",
_55
};
_55
_55
//gets user access token
_55
export const fetchUserToken = async (code: string) => {
_55
try {
_55
const formatData = new URLSearchParams({
_55
...twitterOauthTokenParams,
_55
code,
_55
});
_55
const getTokenRequest = await fetch(
_55
"https://api.twitter.com/2/oauth2/token",
_55
{
_55
method: "POST",
_55
body: formatData.toString(),
_55
headers: {
_55
"Content-Type": "application/x-www-form-urlencoded",
_55
Authorization: `Basic ${BasicAuthToken}`,
_55
},
_55
}
_55
);
_55
const getTokenResponse = await getTokenRequest.json();
_55
return getTokenResponse;
_55
} catch (err) {
_55
return null;
_55
}
_55
};
_55
_55
//gets user's data from the access token
_55
export const fetchUserData = async (accessToken: string) => {
_55
try {
_55
const getUserRequest = await fetch("https://api.twitter.com/2/users/me", {
_55
headers: {
_55
"Content-type": "application/json",
_55
Authorization: `Bearer ${accessToken}`,
_55
},
_55
});
_55
const getUserProfile = await getUserRequest.json();
_55
return getUserProfile;
_55
} catch (err) {
_55
return null;
_55
}
_55
};

  • 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.


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

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.


_10
mkdir utils
_10
cd utils
_10
touch util.ts

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.


_121
export interface Content {
_121
minutes?: number;
_121
content?: string;
_121
published?: boolean;
_121
day?: number;
_121
}
_121
export interface AvailableScheduleItem {
_121
time: number;
_121
schedule: Content[][];
_121
}
_121
// table header
_121
export const tableHeadings: string[] = [
_121
"Time",
_121
"Sunday",
_121
"Monday",
_121
"Tuesday",
_121
"Wednesday",
_121
"Thursday",
_121
"Friday",
_121
"Saturday",
_121
];
_121
_121
// table contents
_121
export const availableSchedule: AvailableScheduleItem[] = [
_121
{
_121
time: 0,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 1,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 2,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 3,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 4,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 5,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 6,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 7,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 8,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 9,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 10,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 11,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 12,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 13,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 14,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 15,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 16,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 17,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 18,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 19,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 20,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 21,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 22,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
{
_121
time: 23,
_121
schedule: [[], [], [], [], [], [], []],
_121
},
_121
];

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


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

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


_96
"use client";
_96
import React, { useState } from "react";
_96
import {
_96
Content,
_96
availableSchedule,
_96
formatTime,
_96
tableHeadings,
_96
} from "@/utils/util";
_96
import { FaClock } from "react-icons/fa6";
_96
_96
const Dashboard = () => {
_96
const [yourSchedule, updateYourSchedule] = useState(availableSchedule);
_96
_96
//👇🏻 add scheduled post
_96
const handleAddPost = (id: number, time: number) => {
_96
console.log({ id, time });
_96
};
_96
_96
//👇🏻 delete scheduled post
_96
const handleDeletePost = (
_96
e: React.MouseEvent<HTMLParagraphElement>,
_96
content: Content,
_96
time: number
_96
) => {
_96
e.stopPropagation();
_96
if (content.day !== undefined) {
_96
console.log({ time, content });
_96
}
_96
};
_96
_96
return (
_96
<main className="min-h-screen w-full">
_96
<header className="mb-6 flex w-full items-center justify-center">
_96
<h2 className="mr-2 text-center text-3xl font-extrabold">
_96
Your Post Schedules
_96
</h2>
_96
<FaClock className="text-3xl text-pink-500" />
_96
</header>
_96
<div className=" p-8">
_96
<div className="h-[80vh] w-full overflow-y-scroll">
_96
<table className="w-full border-collapse">
_96
<thead>
_96
<tr>
_96
{tableHeadings.map((day, index) => (
_96
<th
_96
key={index}
_96
className="bg-[#F8F0DF] p-4 text-lg font-bold"
_96
>
_96
{day}
_96
</th>
_96
))}
_96
</tr>
_96
</thead>
_96
<tbody>
_96
{yourSchedule.map((item, index) => (
_96
<tr key={index}>
_96
<td className="bg-[#F8F0DF] text-lg font-bold">
_96
{formatTime(item.time)}
_96
</td>
_96
{item.schedule.map((sch, id) => (
_96
<td
_96
key={id}
_96
onClick={() => handleAddPost(id, item.time)}
_96
className="cursor-pointer"
_96
>
_96
{sch.map((content, ind: number) => (
_96
<div
_96
key={ind}
_96
onClick={(e) =>
_96
handleDeletePost(e, content, item.time)
_96
}
_96
className={`p-3 ${
_96
content.published ? "bg-pink-500" : "bg-green-600"
_96
} mb-2 cursor-pointer rounded-md text-xs`}
_96
>
_96
<p className="mb-2 text-gray-700">
_96
{content.minutes === 0
_96
? "o'clock"
_96
: `at ${content.minutes} minutes past`}
_96
</p>
_96
<p className=" text-white">{content.content}</p>
_96
</div>
_96
))}
_96
</td>
_96
))}
_96
</tr>
_96
))}
_96
</tbody>
_96
</table>
_96
</div>
_96
</div>
_96
</main>
_96
);
_96
};
_96
_96
export default Dashboard;

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.


_15
export interface DelSelectedCell {
_15
content?: string;
_15
day_id?: number;
_15
day?: string;
_15
time_id?: number;
_15
time?: string;
_15
minutes?: number;
_15
}
_15
export interface SelectedCell {
_15
day_id?: number;
_15
day?: string;
_15
time_id?: number;
_15
time?: string;
_15
minutes?: number;
_15
}

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.


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

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


_28
const handleAddPost = (id: number, time: number) => {
_28
setSelectedCell({
_28
day_id: id + 1,
_28
day: tableHeadings[id + 1],
_28
time_id: time,
_28
time: formatTime(time),
_28
});
_28
setAddPostModal(true);
_28
};
_28
_28
const handleDeletePost = (
_28
e: React.MouseEvent<HTMLParagraphElement>,
_28
content: Content,
_28
time: number
_28
) => {
_28
e.stopPropagation();
_28
if (content.day !== undefined) {
_28
setDelSelectedCell({
_28
content: content.content,
_28
day_id: content.day,
_28
day: tableHeadings[content.day],
_28
time_id: time,
_28
time: formatTime(time),
_28
minutes: content.minutes,
_28
});
_28
setDeletePostModal(true);
_28
}
_28
};

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.


_10
mkdir components
_10
cd components
_10
touch AddPostModal.tsx DeletePostModal.tsx

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


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

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.


_40
import {
_40
SelectedCell,
_40
AvailableScheduleItem,
_40
updateSchedule,
_40
} from "@/utils/util";
_40
import { Dialog, Transition } from "@headlessui/react";
_40
import {
_40
FormEventHandler,
_40
Fragment,
_40
useState,
_40
Dispatch,
_40
SetStateAction,
_40
} from "react";
_40
_40
interface Props {
_40
setAddPostModal: Dispatch<SetStateAction<boolean>>;
_40
updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
_40
addPostModal: boolean;
_40
selectedCell: SelectedCell;
_40
profile: string | any;
_40
yourSchedule: AvailableScheduleItem[];
_40
}
_40
_40
const AddPostModal: React.FC<Props> = ({
_40
setAddPostModal,
_40
addPostModal,
_40
selectedCell,
_40
updateYourSchedule,
_40
profile,
_40
yourSchedule,
_40
}) => {
_40
const [content, setContent] = useState<string>("");
_40
const [minute, setMinute] = useState<number>(0);
_40
const closeModal = () => setAddPostModal(false);
_40
_40
const handleSubmit: FormEventHandler<HTMLFormElement> = (e) => {
_40
e.preventDefault();
_40
}
_40
return ()
_40
}

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.


_90
<div>
_90
<Transition appear show={addPostModal} as={Fragment}>
_90
<Dialog as="div" className="relative z-10" onClose={closeModal}>
_90
<Transition.Child
_90
as={Fragment}
_90
enter="ease-out duration-300"
_90
enterFrom="opacity-0"
_90
enterTo="opacity-100"
_90
leave="ease-in duration-200"
_90
leaveFrom="opacity-100"
_90
leaveTo="opacity-0"
_90
>
_90
<div className="fixed inset-0 bg-black bg-opacity-80" />
_90
</Transition.Child>
_90
_90
<div className="fixed inset-0 overflow-y-auto">
_90
<div className="flex min-h-full items-center justify-center p-4 text-center">
_90
<Transition.Child
_90
as={Fragment}
_90
enter="ease-out duration-300"
_90
enterFrom="opacity-0 scale-95"
_90
enterTo="opacity-100 scale-100"
_90
leave="ease-in duration-200"
_90
leaveFrom="opacity-100 scale-100"
_90
leaveTo="opacity-0 scale-95"
_90
>
_90
<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">
_90
<Dialog.Title
_90
as="h3"
_90
className="text-xl font-bold leading-6 text-gray-900"
_90
>
_90
Schedule a post on {selectedCell.day} by {selectedCell.time}
_90
</Dialog.Title>
_90
_90
<form className="mt-2" onSubmit={handleSubmit}>
_90
{minute > 59 && (
_90
<p className="text-red-600">
_90
Error, please minute must be less than 60
_90
</p>
_90
)}
_90
<label htmlFor="minute" className="opacity-60">
_90
How many minutes past?
_90
</label>
_90
<input
_90
type="number"
_90
className="mb-2 w-full rounded-md border-[1px] px-4 py-2"
_90
name="title"
_90
id="title"
_90
value={minute.toString()}
_90
onChange={(e) => setMinute(parseInt(e.target.value, 10))}
_90
max={59}
_90
required
_90
/>
_90
_90
<label htmlFor="content" className="opacity-60">
_90
Post content
_90
</label>
_90
<textarea
_90
className="mb-2 w-full rounded-md border-[1px] px-4 py-2 text-sm"
_90
name="content"
_90
id="content"
_90
value={content}
_90
onChange={(e) => setContent(e.target.value)}
_90
required
_90
/>
_90
_90
<div className="mt-4 flex items-center justify-between space-x-4">
_90
<button
_90
type="submit"
_90
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"
_90
>
_90
Save
_90
</button>
_90
_90
<button
_90
type="button"
_90
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"
_90
onClick={closeModal}
_90
>
_90
Cancel
_90
</button>
_90
</div>
_90
</form>
_90
</Dialog.Panel>
_90
</Transition.Child>
_90
</div>
_90
</div>
_90
</Dialog>
_90
</Transition>
_90
</div>

AsDownBelow

Update the handleSubmit function as done below.


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

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.


_32
"use client";
_32
import { Dialog, Transition } from "@headlessui/react";
_32
import { Fragment, Dispatch, SetStateAction } from "react";
_32
import {
_32
DelSelectedCell,
_32
AvailableScheduleItem,
_32
updateSchedule,
_32
} from "../utils/util";
_32
_32
interface Props {
_32
setDeletePostModal: Dispatch<SetStateAction<boolean>>;
_32
deletePostModal: boolean;
_32
delSelectedCell: DelSelectedCell;
_32
profile: string | any;
_32
yourSchedule: AvailableScheduleItem[];
_32
updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>;
_32
}
_32
_32
const DeletePostModal: React.FC<Props> = ({
_32
setDeletePostModal,
_32
deletePostModal,
_32
delSelectedCell,
_32
yourSchedule,
_32
updateYourSchedule,
_32
profile,
_32
}) => {
_32
const closeModal = () => setDeletePostModal(false);
_32
_32
const handleDelete = () => {};
_32
_32
return <main>{/**-- JSX elements --**/}</main>;
_32
};

Render these JSX elements within the DeletePostModal component.


_65
return (
_65
<div>
_65
<Transition appear show={deletePostModal} as={Fragment}>
_65
<Dialog as="div" className="relative z-10" onClose={closeModal}>
_65
<Transition.Child
_65
as={Fragment}
_65
enter="ease-out duration-300"
_65
enterFrom="opacity-0"
_65
enterTo="opacity-100"
_65
leave="ease-in duration-200"
_65
leaveFrom="opacity-100"
_65
leaveTo="opacity-0"
_65
>
_65
<div className="fixed inset-0 bg-black bg-opacity-25" />
_65
</Transition.Child>
_65
_65
<div className="fixed inset-0 overflow-y-auto">
_65
<div className="flex min-h-full items-center justify-center p-4 text-center">
_65
<Transition.Child
_65
as={Fragment}
_65
enter="ease-out duration-300"
_65
enterFrom="opacity-0 scale-95"
_65
enterTo="opacity-100 scale-100"
_65
leave="ease-in duration-200"
_65
leaveFrom="opacity-100 scale-100"
_65
leaveTo="opacity-0 scale-95"
_65
>
_65
<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">
_65
<Dialog.Title
_65
as="h3"
_65
className="text-xl font-bold leading-6 text-gray-900"
_65
>
_65
Delete post
_65
</Dialog.Title>
_65
<div className="mt-2">
_65
<p className="mb-3">Are you sure you want to delete?</p>
_65
<p className="text-sm text-gray-500">
_65
{`"${delSelectedCell.content}" scheduled for ${delSelectedCell.day} at ${delSelectedCell.time_id}:${delSelectedCell.minutes}`}
_65
</p>
_65
</div>
_65
_65
<div className="mt-4 flex items-center justify-between space-x-4">
_65
<button
_65
type="button"
_65
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"
_65
onClick={handleDelete}
_65
>
_65
Yes
_65
</button>
_65
<button
_65
type="button"
_65
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"
_65
onClick={closeModal}
_65
>
_65
Cancel
_65
</button>
_65
</div>
_65
</Dialog.Panel>
_65
</Transition.Child>
_65
</div>
_65
</div>
_65
</Dialog>
_65
</Transition>
_65
</div>
_65
);

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


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

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.


_10
npm install @supabase/supabase-js

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.


_10
NEXT_PUBLIC_SUPABASE_URL=<public_supabase_URL>
_10
NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase_anon_key>

SupabaseApi

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


_10
import { createClient } from "@supabase/supabase-js";
_10
_10
const supabaseURL = process.env.NEXT_PUBLIC_SUPABASE_URL!;
_10
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;
_10
_10
export const supabase = createClient(supabaseURL, supabaseAnonKey, {
_10
auth: { persistSession: false },
_10
});

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.


_10
cd pages/api
_10
mkdir schedule
_10
touch create.ts delete.ts read.ts

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


_31
import { supabase } from "../../../../supabaseClient";
_31
_31
export default async function handler(
_31
req: NextApiRequest,
_31
res: NextApiResponse
_31
) {
_31
const { code } = req.body;
_31
try {
_31
const tokenResponse = await fetchUserToken(code);
_31
const accessToken = tokenResponse.access_token;
_31
_31
if (accessToken) {
_31
const userDataResponse = await fetchUserData(accessToken);
_31
const userCredentials = { ...tokenResponse, ...userDataResponse };
_31
//👇🏻 saves user's information to Supabase
_31
const { data, error } = await supabase.from("users").insert({
_31
id: userCredentials.data.id,
_31
accessToken: userCredentials.access_token,
_31
username: userCredentials.data.username,
_31
});
_31
if (!error) {
_31
return res.status(200).json(userCredentials);
_31
} else {
_31
c;
_31
return res.status(400).json({ error });
_31
}
_31
}
_31
} catch (err) {
_31
return res.status(400).json({ err });
_31
}
_31
}

Scheduling new posts

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


_18
import type { NextApiRequest, NextApiResponse } from "next";
_18
import { supabase } from "../../../../supabaseClient";
_18
_18
export default async function handler(
_18
req: NextApiRequest,
_18
res: NextApiResponse
_18
) {
_18
const { profile, timestamp, content, published, day_id } = req.body;
_18
_18
const { data, error } = await supabase.from("schedule_posts").insert({
_18
profile,
_18
timestamp,
_18
content,
_18
published,
_18
day_id,
_18
});
_18
res.status(200).json({ data, error });
_18
}

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.


_37
export const getNextDayOfWeek = (
_37
dayOfWeek: number,
_37
hours: number,
_37
minutes: number
_37
) => {
_37
var today = new Date();
_37
var daysUntilNextDay = dayOfWeek - today.getDay();
_37
if (daysUntilNextDay < 0) {
_37
daysUntilNextDay += 7;
_37
}
_37
today.setDate(today.getDate() + daysUntilNextDay);
_37
today.setHours(hours);
_37
today.setMinutes(minutes);
_37
return today;
_37
};
_37
_37
export const updateSchedule = async (profile: string, schedule: any) => {
_37
const { day_id, time, minutes, content, published } = schedule;
_37
const timestampFormat = getNextDayOfWeek(day_id, time, minutes);
_37
try {
_37
await fetch("/api/schedule/create", {
_37
method: "POST",
_37
body: JSON.stringify({
_37
profile,
_37
timestamp: timestampFormat,
_37
content,
_37
published,
_37
day_id,
_37
}),
_37
headers: {
_37
"Content-Type": "application/json",
_37
},
_37
});
_37
} catch (err) {
_37
console.error(err);
_37
}
_37
};

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.


_17
import type { NextApiRequest, NextApiResponse } from "next";
_17
import { supabase } from "../../../../supabaseClient";
_17
_17
export default async function handler(
_17
req: NextApiRequest,
_17
res: NextApiResponse
_17
) {
_17
const { profile, timestamp, content } = req.body;
_17
_17
const { data, error } = await supabase
_17
.from("schedule_posts")
_17
.delete()
_17
.eq("content", content)
_17
.eq("timestamp", timestamp.toISOString());
_17
_17
res.status(200).json({ data, error });
_17
}

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


_16
export const deleteSchedule = async (profile: string, schedule: any) => {
_16
const { day_id, time_id, minutes, content, published } = schedule;
_16
const timestampFormat = getNextDayOfWeek(day_id, time_id, minutes);
_16
_16
try {
_16
await fetch("/api/schedule/delete", {
_16
method: "POST",
_16
body: JSON.stringify({ profile, timestamp: timestampFormat, content }),
_16
headers: {
_16
"Content-Type": "application/json",
_16
},
_16
});
_16
} catch (err) {
_16
console.error(err);
_16
}
_16
};

Fetching users’ schedule

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


_19
// Next.js API route support: https://nextjs.org/docs/api-routes/introduction
_19
import type { NextApiRequest, NextApiResponse } from "next";
_19
import { supabase } from "../../../../supabaseClient";
_19
_19
export default async function handler(
_19
req: NextApiRequest,
_19
res: NextApiResponse
_19
) {
_19
const { profile, timestamp, content, published, day_id } = req.body;
_19
_19
const { data, error } = await supabase.from("schedule_posts").insert({
_19
profile,
_19
timestamp,
_19
content,
_19
published,
_19
day_id,
_19
});
_19
res.status(200).json({ data, error });
_19
}

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


_47
export const fetchSchedule = async (
_47
profile: string,
_47
updateYourSchedule: Dispatch<SetStateAction<AvailableScheduleItem[]>>
_47
) => {
_47
try {
_47
const request = await fetch("/api/schedule/read", {
_47
method: "POST",
_47
body: JSON.stringify({ profile }),
_47
headers: {
_47
"Content-Type": "application/json",
_47
},
_47
});
_47
const response = await request.json();
_47
const { data } = response;
_47
//👇🏻 if there is a response
_47
if (data) {
_47
const result = data.map((item: any) => {
_47
const date = new Date(item.timestamp);
_47
//👇🏻 returns a new array in the format below
_47
return {
_47
//👇🏻 converts 24 hour to 0 hour
_47
time: date.getUTCHours() + 1 < 24 ? date.getUTCHours() + 1 : 0,
_47
schedule: {
_47
content: item.content,
_47
published: item.published,
_47
minutes: date.getUTCMinutes(),
_47
day: item.day_id,
_47
},
_47
};
_47
});
_47
//👇🏻 loops through retrieved schedule and add them to the large table array
_47
result.forEach((object: any) => {
_47
const matchingObjIndex = availableSchedule.findIndex(
_47
(largeObj) => largeObj.time === object.time
_47
);
_47
if (matchingObjIndex !== -1) {
_47
availableSchedule[matchingObjIndex].schedule[
_47
object.schedule.day
_47
].push(object.schedule);
_47
}
_47
});
_47
updateYourSchedule(availableSchedule);
_47
}
_47
} catch (err) {
_47
console.error(err);
_47
}
_47
};

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.


_10
npx @trigger.dev/cli@latest init

Start your Next.js project.


_10
npm run dev

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


_10
npx @trigger.dev/cli@latest dev

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.


_10
npm install @trigger.dev/supabase

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.


_10
import { cronTrigger } from "@trigger.dev/sdk";
_10
import { Supabase } from "@trigger.dev/supabase";
_10
_10
const supabase = new Supabase({
_10
id: "supabase",
_10
supabaseUrl: process.env.NEXT_PUBLIC_SUPABASE_URL!,
_10
supabaseKey: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
_10
});

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.


_27
client.defineJob({
_27
id: "post-schedule",
_27
name: "Post Schedule",
_27
//👇🏻 integrate Supabase
_27
integrations: { supabase },
_27
version: "0.0.1",
_27
//👇🏻 runs every minute
_27
trigger: cronTrigger({
_27
cron: "* * * * *",
_27
}),
_27
run: async (payload, io, ctx) => {
_27
await io.logger.info("Job started! 🌟");
_27
_27
const { data, error } = await io.supabase.runTask(
_27
"find-schedule",
_27
async (db) => {
_27
return await db
_27
.from("schedule_posts")
_27
.select(`*, users (username, accessToken)`)
_27
.eq("published", false)
_27
.lt("timestamp", new Date().toISOString());
_27
}
_27
);
_27
_27
await io.logger.info(JSON.stringify(data));
_27
},
_27
});

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.


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

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

Ready to start building?

Build and deploy your first task in 3 minutes.

Get started now
,