API GuidesNovember 4, 202610 min readingMarcel Czuryszkiewicz

Build a Social Media Scheduler with Next.js and bundle.social

Build a social media scheduler with Next.js: a route handler uploads a JPEG and creates draft or scheduled Instagram and LinkedIn posts. Starter included.

To build a social media scheduler with Next.js, put a composer page in the App Router, send its form to your own route handler, and let that handler call the bundle.social API with a server-side key: POST /api/v1/upload/from-url for the image, then POST /api/v1/post/ with status DRAFT or SCHEDULED. bundle.social publishes the post at its postDate, and the same route reads the status back.

The tutorial walks through a small starter that does exactly that: one team, a JPEG image, an Instagram feed post or a LinkedIn post, draft and schedule buttons, and a result view.

Download the Next.js scheduler starter: nextjs-social-media-scheduler.zip

  • src/app/page.tsx: composer and result view
  • src/app/api/posts/route.ts: route handler that lists, reads and creates posts
  • src/lib/bundle.ts: API client, input validation, demo login and team checks
  • .env.example and README.md

The files are an overlay for a fresh create-next-app project.

For related code, including Express, Python and Make examples, see the public bundlesocial-examples repository. This starter is a separate, smaller project.

Before you start

  • A bundle.social account. The Free plan is enough for testing: 20 posts a month, 3 teams and API access, and drafts do not count toward the post quota.
  • An API key from Organization > API Keys.
  • A team with a connected Instagram business or creator account or a LinkedIn profile or page, plus that team's ID. GET https://api.bundle.social/api/v1/team/ with the x-api-key header lists your teams.
  • A public HTTPS link to a JPEG for testing. If your test image is a PNG or the wrong shape, the social media image resizer crops it to a platform size and saves a JPEG in your browser; then host that file at a public HTTPS URL.

Set up the Next.js social media scheduler

Create an App Router project with TypeScript and a src directory, so the starter's paths match:

npx create-next-app@latest bundle-scheduler --ts --app --src-dir --eslint --no-tailwind --import-alias "@/*" --use-npm --yes
cd bundle-scheduler

--import-alias "@/*" matters because the route imports @/lib/bundle, and --yes accepts the defaults for the remaining prompts. Tailwind is off because the starter uses inline styles.

Copy the starter's src folder over the project, which replaces the generated src/app/page.tsx and keeps layout.tsx. Then copy .env.example to .env.local and fill in real values:

BUNDLE_API_KEY=your_bundle_key
BUNDLE_TEAM_ID=your_connected_team
STARTER_USERNAME=your_demo_user
STARTER_PASSWORD=replace_with_a_long_random_password
APP_ORIGIN=http://localhost:3000

None of these values has a NEXT_PUBLIC_ prefix, so none of them reaches the browser. The helper refuses placeholder values and passwords shorter than 16 characters, and APP_ORIGIN must match the origin the page runs on. Start the app with npm run dev. The project now looks like this:

bundle-scheduler/
  .env.local                 your keys, copied from .env.example
  src/
    app/
      layout.tsx             generated by create-next-app
      page.tsx               composer and result view (client component)
      api/posts/route.ts     GET lists or reads posts, POST creates one
    lib/
      bundle.ts              API client, validation, demo login, team checks
A cook in a white jacket reaching up to take paper order tickets hanging from a rail under a stainless steel shelf, face out of frame
The browser places the order, and the route handler on the server does the work with the API key.

Keep the API client on the server

Every call to bundle.social goes through one helper in src/lib/bundle.ts:

export class ApiError extends Error {
  constructor(public status: number, message: string) {
    super(message);
  }
}

export async function bundleRequest(path: string, init: RequestInit = {}): Promise<Record<string, unknown>> {
  const headers = new Headers(init.headers);
  headers.set("x-api-key", required("BUNDLE_API_KEY"));
  if (init.body) headers.set("Content-Type", "application/json");

  const response = await fetch("https://api.bundle.social/api/v1" + path, {
    ...init,
    headers,
    cache: "no-store",
    signal: AbortSignal.timeout(25_000),
  });

  // Read text first, so an HTML error page or an empty body cannot crash JSON parsing.
  const raw = await response.text();
  let body: Record<string, unknown>;
  try {
    body = JSON.parse(raw);
  } catch {
    throw new ApiError(502, "Publishing API returned a non-JSON response");
  }
  if (!response.ok) {
    const message = typeof body.message === "string" ? body.message : `Publishing API returned HTTP ${response.status}`;
    throw new ApiError(response.status, message);
  }
  return body;
}

Reading the body as text before parsing turns a proxy error page into a clear 502 instead of an unhandled exception. API errors keep their status code, so the route can pass a 400 or 409 back to the browser unchanged. required() reads an environment variable and throws a 503 when it is missing or still a placeholder. Only route handlers import this file, so the API key stays on the server.

Create the post in a route handler

src/app/api/posts/route.ts handles both reading and creating:

import { ApiError, authorize, checkOrigin, bundleRequest, ownedPost, teamId, validateSubmission, submitPost } from "@/lib/bundle";

export const runtime = "nodejs";

function failure(error: unknown) {
  const status = error instanceof ApiError ? error.status : error instanceof SyntaxError ? 400 : 500;
  return Response.json({ message: error instanceof Error ? error.message : "Request failed" }, { status });
}

export async function GET(request: Request) {
  try {
    const denied = authorize(request);
    if (denied) return denied;
    const id = new URL(request.url).searchParams.get("id");
    if (id) return Response.json(await ownedPost(id));
    const query = new URLSearchParams({ teamId: teamId(), orderBy: "postDate", order: "DESC", limit: "50" });
    return Response.json(await bundleRequest("/post/?" + query));
  } catch (error) {
    return failure(error);
  }
}

export async function POST(request: Request) {
  try {
    const denied = authorize(request);
    if (denied) return denied;
    checkOrigin(request);
    const raw = await request.text();
    if (raw.length > 16_000) throw new ApiError(413, "Request is too large");
    const input = validateSubmission(JSON.parse(raw));
    return Response.json(await submitPost(input));
  } catch (error) {
    return failure(error);
  }
}

validateSubmission checks every field before any API call: the platform must be Instagram or LinkedIn, the mode DRAFT or SCHEDULED, the time must include a timezone and lie at least a minute ahead, and the image URL must use HTTPS. GET without an id lists the team's 50 posts with the latest postDate; with an id it returns one post after checking that it belongs to the configured team.

Upload the image, then create the post

submitPost imports the image and builds the post body:

export function postBody(input: Submission, uploadId: string) {
  const content: Record<string, unknown> = { text: input.caption, uploadIds: [uploadId] };
  if (input.platform === "INSTAGRAM") content.type = "POST";
  return {
    teamId: teamId(),
    title: input.title,
    referenceKey: input.referenceKey,
    status: input.mode, // "DRAFT" or "SCHEDULED"
    postDate: input.postDate,
    socialAccountTypes: [input.platform],
    data: { [input.platform]: content },
  };
}

export async function submitPost(input: Submission) {
  // A repeated submission returns the post that already exists.
  try {
    const existing = await bundleRequest("/post/reference-key/" + encodeURIComponent(input.referenceKey));
    if (existing.teamId !== teamId()) throw new ApiError(403, "Reference belongs to another team");
    return existing;
  } catch (error) {
    if (!(error instanceof ApiError) || error.status !== 404) throw error;
  }

  const upload = await bundleRequest("/upload/from-url", {
    method: "POST",
    body: JSON.stringify({ teamId: teamId(), url: input.mediaUrl }),
  });
  if (
    typeof upload.id !== "string" ||
    upload.type !== "image" ||
    typeof upload.mime !== "string" ||
    !["image/jpeg", "image/jpg"].includes(upload.mime.toLowerCase())
  ) {
    throw new ApiError(400, "This starter requires a JPEG image");
  }

  return bundleRequest("/post/", { method: "POST", body: JSON.stringify(postBody(input, upload.id)) });
}

bundle.social downloads the file from the URL and returns an upload object with id, type and mime, which the starter checks before it creates anything. Instagram gets type: "POST" for a feed post; LinkedIn gets the same text and upload without a type. The response is the new post with its id and status. To see roughly where Instagram or LinkedIn folds a caption and how it crops the image, mock the post first in the social media post preview.

The Free plan's 20 posts a month cover the draft and schedule tests.

bundle.social

Your route handler sends the post, and publishing at postDate, retries and status updates run on our side.

Queue a whole calendar and let the scheduler publish it, platform by platform.

Why every submission carries a reference key

The composer generates a key such as next: followed by a UUID for each submission. bundle.social keeps referenceKey unique within an organization: a second create with the same key returns HTTP 409, "A post with this referenceKey already exists in this organization", and GET /api/v1/post/reference-key/{referenceKey} returns the existing post.

The starter looks the key up before creating, so a retry after a lost response returns the post that already exists. If two identical requests arrive at the same moment, the slower one gets the 409 and the route returns it. The composer keeps the same key after an error, so the next click finds the post through the lookup, and it generates a new key only after a successful submission. In a production app, store each submission in your database under its reference key as well.

Protect the route and resolve the team

The demo protects both routes with Basic authentication against STARTER_USERNAME and STARTER_PASSWORD, compared in constant time. POST requests must also come from APP_ORIGIN. The team always comes from BUNDLE_TEAM_ID, never from the browser, and ownedPost rejects a post ID from another team.

For a SaaS product, replace the demo login with your real session layer and look up each customer's team in your database on every request. The API key belongs to your organization and works for all of its teams, so this check is the only thing that keeps one customer out of another's posts. Our guide to multi-tenant social media API architecture goes further into keeping customers apart.

A pedestrian traffic light showing a green walking figure in a hat against a cloudy blue sky, the red lamp above it switched off
Show a post as published only when its status reaches POSTED.

Show queued and published states

The composer converts the local datetime-local value to an ISO timestamp with toISOString(), so the server always receives UTC. Create draft sends DRAFT, and Schedule sends SCHEDULED; the buttons stay disabled while a request runs.

The result panel shows the returned post, and Check publishing result reads it again through GET /api/posts?id=. A post moves from SCHEDULED through PROCESSING (or RETRYING) to POSTED or ERROR. Show it as published only at POSTED, with the link from externalData.INSTAGRAM.permalink or externalData.LINKEDIN.permalink. For an ERROR, show the message from errorsVerbose.

The demo refreshes on request. For push updates, add a route that receives the post.published webhook: read the raw body with await request.text(), compare the x-signature header with the hex HMAC-SHA256 of that body using your webhook secret, and only then parse the JSON. The Instagram messaging API tutorial shows the verification code in Node.js and Python.

Extend the starter into your product

  • A calendar view lists posts with GET /api/v1/post/?teamId=...&postDateFrom=...&postDateTo=...&orderBy=postDate.
  • PATCH /api/v1/post/{id} changes a post that has not been published yet, and DELETE /api/v1/post/{id} cancels a scheduled one.
  • POST /api/v1/social-account/create-portal-link gives a customer a hosted page to connect accounts to their own team.
  • After the customer reconnects an expired account, POST /api/v1/post/{id}/retry publishes a failed post again.
  • Instagram also accepts REEL and STORY. Extend validateSubmission and postBody for video uploads, and run test files through the video specs checker, which reads codecs, resolution, bitrate and frame rate on your device and compares them with each platform's specs.

The scheduling API guide, SaaS API page and white-label API cover the multi-customer side, and the Next.js route handler documentation covers the framework details.

Frequently asked questions

Do I need a paid bundle.social plan to build this scheduler?

No. The Free plan includes API access, 20 posts a month and 3 teams, and drafts do not count toward the post quota, so you can build and test the whole flow. Pro costs $100 a month, or $90 billed yearly, and adds 10,000 posts a month, unlimited teams and analytics.

Can the browser call the bundle.social API directly?

It should not. The API key belongs to your whole organization, and any value sent to the browser can be read by users. The browser calls your own route handler, which checks the user, picks their team on the server and adds the x-api-key header. None of the environment variables uses the NEXT_PUBLIC_ prefix.

Does the starter support video, Reels or other networks?

The starter accepts JPEG images for Instagram feed posts and LinkedIn only, and it rejects anything else before creating a post. The API itself publishes to 15 networks and supports Instagram REEL and STORY types. To add them, extend validateSubmission for the new inputs and postBody for each platform's fields.

How does the app know a scheduled post went live?

Read the post with GET /api/v1/post/{id} and check status: POSTED means it is live, and externalData holds the permalink. ERROR means it failed, with details in errorsVerbose. For updates without polling, receive the post.published webhook and verify its x-signature header before trusting it.

What happens if a user clicks Schedule twice?

The buttons are disabled while a request runs, and the composer creates a new reference key only after a successful submission. If the same submission still arrives twice, the lookup returns the existing post, or bundle.social answers the second create with HTTP 409. Either way, only one post is created.

Marcel Czuryszkiewicz
Written by

Marcel Czuryszkiewicz

Co-Founder

Marcel is one of the builders behind bundle.social, creating a social media API with a strong focus on developer experience, reliability, and real support. Before bundle.social, he worked at Samsung R&D, Reply AI, and Docplanner, building internal tools that helped companies grow. He brought that same practical, product-focused mindset to bundle.social, so you can trust that your project is in good hands.

Keep reading