Guides/Next.js

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.

~20 minNext.js 14+TypeScriptApp Router
Prerequisites
  • • 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 aether

Then 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-keys

Step 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>
  );
}
Local development note:Instagram's OAuth requires a public HTTPS URL — localhost won't work. Use 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:",
    },
  },
});
What you've built
  • 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.