Post to Instagram via API from Next.js
Step-by-step guide: connect an Instagram Business account, create posts with images, schedule for a future time, and handle publish confirmations via webhook — all from a Next.js App Router application.
- • Node.js 18+ and an existing Next.js 14+ project
- • An Instagram Business or Creator account (personal accounts cannot post via API)
- • An Aether account — free, no credit card required
Step 1 — Install the SDK
Install the Aether Node.js SDK:
npm install aetherThen add your API key to .env.local:
# .env.local
AETHER_API_KEY=your_api_key_here
# Get your key at https://aetherhq.dev/dashboard/api-keysStep 2 — Create an Aether client singleton
Create a shared client module so you don't re-instantiate on every request:
// lib/aether.ts
import Aether from "aether";
// Singleton — reuse across server components and route handlers
export const aether = new Aether({
apiKey: process.env.AETHER_API_KEY!,
});Step 3 — Connect an Instagram account
Instagram requires OAuth — your user must grant your app permission to post on their behalf. Aether handles the full OAuth flow via Connect Links. You generate a URL, redirect the user to it, and receive a profileId on callback.
Create a route handler that generates the Connect Link:
// app/api/instagram/connect/route.ts
import { NextResponse } from "next/server";
import { aether } from "@/lib/aether";
export async function GET() {
const link = await aether.connectLinks.create({
platform: "instagram",
// After OAuth, Instagram redirects here
redirectUrl: `${process.env.NEXT_PUBLIC_APP_URL}/api/instagram/callback`,
});
// Redirect the user to Instagram's OAuth screen
return NextResponse.redirect(link.url);
}Handle the callback and save the profile ID:
// app/api/instagram/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/lib/db"; // your database client
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const connected = searchParams.get("connected");
const profileId = searchParams.get("profileId"); // "ig_abc123"
const platform = searchParams.get("platform"); // "instagram"
if (connected !== "true" || !profileId) {
return NextResponse.redirect(
new URL("/settings?error=oauth_failed", req.url)
);
}
// Associate this Instagram profile with the current user
// (get userId from your session/auth system)
await db.socialProfile.upsert({
where: { profileId },
update: { platform, connectedAt: new Date() },
create: { userId: "usr_123", profileId, platform },
});
return NextResponse.redirect(
new URL("/settings?connected=instagram", req.url)
);
}Add a connect button to your settings page:
// app/settings/page.tsx (or any server component)
import Link from "next/link";
export default function SettingsPage() {
return (
<div>
<h1>Connected accounts</h1>
{/* Clicking this link kicks off the OAuth flow */}
<Link href="/api/instagram/connect">
Connect Instagram
</Link>
</div>
);
}ngrok http 3000 to expose your local server, and set NEXT_PUBLIC_APP_URL to the ngrok URL in your .env.local.Step 4 — Post to Instagram
With a connected profile ID, you can post immediately or schedule for later. Here's a Route Handler implementation:
// app/api/posts/route.ts
import { NextRequest, NextResponse } from "next/server";
import { aether } from "@/lib/aether";
export async function POST(req: NextRequest) {
const { text, profileId, mediaUrls, scheduledFor } = await req.json();
const post = await aether.posts.create({
text,
profileIds: [profileId], // e.g. "ig_abc123"
mediaUrls, // optional — direct URLs to images/videos
scheduledFor, // optional ISO 8601 — omit to publish immediately
});
return NextResponse.json(post.data);
}
// Example request body:
// {
// "text": "Check out our latest update! #saas #launch",
// "profileId": "ig_abc123",
// "mediaUrls": ["https://your-cdn.com/launch-image.jpg"],
// "scheduledFor": "2026-07-20T09:00:00Z"
// }Or use a Server Action to call directly from a form:
// app/actions/post.ts — Server Action (Next.js 14+)
"use server";
import { aether } from "@/lib/aether";
import { revalidatePath } from "next/cache";
export async function createInstagramPost(formData: FormData) {
const text = formData.get("text") as string;
const profileId = formData.get("profileId") as string;
const imageUrl = formData.get("imageUrl") as string | null;
const scheduleAt = formData.get("scheduleAt") as string | null;
const post = await aether.posts.create({
text,
profileIds: [profileId],
...(imageUrl && { mediaUrls: [imageUrl] }),
...(scheduleAt && { scheduledFor: scheduleAt }),
});
revalidatePath("/posts");
return post.data;
}// app/posts/new/page.tsx — form that calls the Server Action
import { createInstagramPost } from "@/app/actions/post";
export default function NewPostPage() {
return (
<form action={createInstagramPost}>
<input type="hidden" name="profileId" value="ig_abc123" />
<textarea
name="text"
placeholder="Write your caption..."
maxLength={2200}
required
/>
<input
type="url"
name="imageUrl"
placeholder="Image URL (optional)"
/>
<input
type="datetime-local"
name="scheduleAt"
placeholder="Schedule for (optional)"
/>
<button type="submit">Post to Instagram</button>
</form>
);
}Step 5 — Confirm publish with webhooks
Aether fires a post.published webhook when Instagram confirms the post is live, and post.failed if it fails after all retries. Create a webhook receiver route:
// app/api/webhooks/aether/route.ts — Receive publish confirmations
import { NextRequest, NextResponse } from "next/server";
import { createHmac, timingSafeEqual } from "crypto";
export async function POST(req: NextRequest) {
// 1. Verify the signature — never skip this in production
const rawBody = await req.text();
const sigHeader = req.headers.get("x-aether-signature") ?? "";
const expected = `sha256=${
createHmac("sha256", process.env.AETHER_WEBHOOK_SECRET!)
.update(rawBody)
.digest("hex")
}`;
if (!timingSafeEqual(Buffer.from(expected), Buffer.from(sigHeader))) {
return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
}
// 2. Parse and handle
const payload = JSON.parse(rawBody);
if (payload.event === "post.published") {
const { postId, platform, platformPostUrl } = payload.data;
console.log(`Published on ${platform}: ${platformPostUrl}`);
// Update your DB, notify the user, etc.
}
if (payload.event === "post.failed") {
const { postId, platform, error } = payload.data;
console.error(`Failed on ${platform}: ${error.message}`);
// Alert the user, retry, etc.
}
return NextResponse.json({ received: true });
}Register your webhook endpoint once:
// Register your webhook once — run this in a setup script or on app init
import { aether } from "@/lib/aether";
const webhook = await aether.webhooks.create({
url: `${process.env.NEXT_PUBLIC_APP_URL}/api/webhooks/aether`,
events: ["post.published", "post.failed", "comment.created"],
});
// Save webhook.secret to your env as AETHER_WEBHOOK_SECRET
console.log("Webhook secret:", webhook.secret);Bonus — Per-platform overrides
When posting to multiple platforms simultaneously, use overrides to tailor text per platform without a separate API call:
// Per-platform overrides — useful when posting to multiple platforms
const post = await aether.posts.create({
text: "Default caption for platforms not in overrides",
profileIds: ["ig_abc123", "li_comp789"],
overrides: {
"ig_abc123": {
// Instagram-specific: hashtags, casual tone
text: "New update just dropped 🚀 #saas #buildinpublic #startup",
},
"li_comp789": {
// LinkedIn-specific: professional tone, no hashtag spam
text: "We shipped a significant product update today. Here's what changed and why:",
},
},
});- ✓SDK installed, API key in .env.local
- ✓Shared Aether client singleton
- ✓Connect Link OAuth flow — GET /api/instagram/connect
- ✓Callback handler — GET /api/instagram/callback
- ✓Post creator — POST /api/posts (Route Handler or Server Action)
- ✓Webhook receiver — POST /api/webhooks/aether with HMAC verification
Ready to ship?
Grab your free API key and have your first post live in under 20 minutes.