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 viewsrc/app/api/posts/route.ts: route handler that lists, reads and creates postssrc/lib/bundle.ts: API client, input validation, demo login and team checks.env.exampleandREADME.mdThe files are an overlay for a fresh
create-next-appproject.
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 thex-api-keyheader 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

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.

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, andDELETE /api/v1/post/{id}cancels a scheduled one.POST /api/v1/social-account/create-portal-linkgives a customer a hosted page to connect accounts to their own team.- After the customer reconnects an expired account,
POST /api/v1/post/{id}/retrypublishes a failed post again. - Instagram also accepts
REELandSTORY. ExtendvalidateSubmissionandpostBodyfor 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.