Get visibility on X (Twitter): Schedule your posts with NextJS
CTO, Trigger.dev
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.
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
Let's set it up 🔥
Let's create a TypeScript Next.js application by running the code snippet below.
_10npx 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.
_10npm install @headlessui/react react-icons
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.
Provide a suitable project name and answer all the required questions.
Next, create an App under the project and copy the tokens generated into a .env.local
file within your Next.js project.
_10TWITTER_API_KEY=<your_api_key>_10TWITTER_API_SECRET=<your_api_secret>_10TWITTER_BEARER_TOKEN=<your_bearer_token>
Scroll down the page and set up user authentication to enable users to sign in to your application via X.
Select Read and write
as the app permission, enable Request email from users
, and select Web app
as the type of app.
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.
After setting up the authentication process, save the OAuth 2.0 Client ID and secret into the .env.local
file.
_10TWITTER_CLIENT_ID=<app_client_id>_10TWITTER_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.
_38import { Inter } from "next/font/google";_38const inter = Inter({ subsets: ["latin"] });_38import Link from "next/link";_38_38export 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.
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.
_10http://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.
_33import React, { useCallback, useEffect } from "react";_33_33const 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_33export 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.
_55import type { NextApiRequest, NextApiResponse } from "next";_55_55const BasicAuthToken = Buffer.from(_55 `${process.env.TWITTER_CLIENT_ID!}:${process.env.TWITTER_CLIENT_SECRET!}`,_55 "utf8"_55).toString("base64");_55_55const 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_55export 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_55export 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 thefetchUserData
function accepts the token and retrieves the user's X profile.
- The
Finally, create the endpoint as done below.
_17export 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.
Before we proceed, create a utils/util.ts
file. It will contain some of the functions used within the application.
_10mkdir utils_10cd utils_10touch 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.
_121export interface Content {_121 minutes?: number;_121 content?: string;_121 published?: boolean;_121 day?: number;_121}_121export interface AvailableScheduleItem {_121 time: number;_121 schedule: Content[][];_121}_121// table header_121export const tableHeadings: string[] = [_121 "Time",_121 "Sunday",_121 "Monday",_121 "Tuesday",_121 "Wednesday",_121 "Thursday",_121 "Friday",_121 "Saturday",_121];_121_121// table contents_121export 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.
_13export 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";_96import React, { useState } from "react";_96import {_96 Content,_96 availableSchedule,_96 formatTime,_96 tableHeadings,_96} from "@/utils/util";_96import { FaClock } from "react-icons/fa6";_96_96const 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_96export 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.
_15export interface DelSelectedCell {_15 content?: string;_15 day_id?: number;_15 day?: string;_15 time_id?: number;_15 time?: string;_15 minutes?: number;_15}_15export 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.
_18const [selectedCell, setSelectedCell] = useState<SelectedCell>({_18 day_id: 0,_18 day: "",_18 time_id: 0,_18 time: "",_18});_18const [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_18const [addPostModal, setAddPostModal] = useState(false);_18//👇🏻 triggers the delete post modal_18const [deletePostModal, setDeletePostModal] = useState(false);
Modify the handleAddPost
and handleDeletePost
functions to update the recently added states as follows.
_28const 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_28const 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.
_10mkdir components_10cd components_10touch AddPostModal.tsx DeletePostModal.tsx
Display both components within the dashboard.tsx
when the user wants to add or delete a post.
_25return (_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.
_40import {_40 SelectedCell,_40 AvailableScheduleItem,_40 updateSchedule,_40} from "@/utils/util";_40import { Dialog, Transition } from "@headlessui/react";_40import {_40 FormEventHandler,_40 Fragment,_40 useState,_40 Dispatch,_40 SetStateAction,_40} from "react";_40_40interface 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_40const 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_40const 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>
Update the handleSubmit
function as done below.
_21const 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.
Add the code snippet below into the DeletePostModal.tsx
file.
_32"use client";_32import { Dialog, Transition } from "@headlessui/react";_32import { Fragment, Dispatch, SetStateAction } from "react";_32import {_32 DelSelectedCell,_32 AvailableScheduleItem,_32 updateSchedule,_32} from "../utils/util";_32_32interface 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_32const 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.
_65return (_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.
_27const 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 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.
_10npm install @supabase/supabase-js
Visit the Supabase homepage and create a new organization and project.
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.
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.
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.
Click API
on the sidebar menu and copy the project's URL and API into the .env.local
file.
_10NEXT_PUBLIC_SUPABASE_URL=<public_supabase_URL>_10NEXT_PUBLIC_SUPABASE_ANON_KEY=<supabase_anon_key>
Finally, create a src/supbaseClient.ts
file and create the Supabase client for the application.
_10import { createClient } from "@supabase/supabase-js";_10_10const supabaseURL = process.env.NEXT_PUBLIC_SUPABASE_URL!;_10const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!;_10_10export 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.
_10cd pages/api_10mkdir schedule_10touch 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.
_31import { supabase } from "../../../../supabaseClient";_31_31export 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.
_18import type { NextApiRequest, NextApiResponse } from "next";_18import { supabase } from "../../../../supabaseClient";_18_18export 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.
_37export 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_37export 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.
_17import type { NextApiRequest, NextApiResponse } from "next";_17import { supabase } from "../../../../supabaseClient";_17_17export 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.
_16export 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_19import type { NextApiRequest, NextApiResponse } from "next";_19import { supabase } from "../../../../supabaseClient";_19_19export 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.
_47export 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.
Select Next.js as your framework and follow the process for adding Trigger.dev to an existing Next.js project.
Otherwise, click Environments & API Keys
on the sidebar menu of your project dashboard.
Copy your DEV server API key and run the code snippet below to install Trigger.dev. Follow the instructions carefully.
_10npx @trigger.dev/cli@latest init
Start your Next.js project.
_10npm run dev
In another terminal, run the following code snippet to establish a tunnel between Trigger.dev and your Next.js project.
_10npx @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.
_10npm 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.
_10import { cronTrigger } from "@trigger.dev/sdk";_10import { Supabase } from "@trigger.dev/supabase";_10_10const 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.
_27client.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
.
Finally, iterate through the posts and post its content on X.
_19for (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
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