Documentation

Alfsight is a self-hosted alternative to Elfsight for embedding Instagram feeds on any website. It fetches public Instagram data via the OpenHandle API, caches it at the edge with Cloudflare KV, and serves it through a zero-dependency Web Component that weighs under 6 kB gzipped.

Architecture: Monorepo with pnpm workspaces — API (Hono on Cloudflare Workers), Dashboard (Astro + React), Widget (vanilla Web Component), and shared packages for types and validation. Data flows: visitor loads widget → widget calls API → API checks KV cache → cache miss hits D1 → cron syncs from OpenHandle.

Prerequisites

  • Node.js ≥ 18
  • pnpm ≥ 8 — install with npm install -g pnpm
  • A Cloudflare account (free tier works) for Workers, D1, and KV
  • An OpenHandle API key from openhandle.dev (test key is free)
  • Git installed

Fork & clone

Fork the repository on GitHub, then clone your fork locally:

terminal
# Fork via GitHub UI or CLI
gh repo fork Manish-Tamang/alfsight --clone

# Or clone manually after forking
git clone https://github.com/YOUR_USERNAME/alfsight.git
cd alfsight

If you just want to try it without forking:

terminal
git clone https://github.com/Manish-Tamang/alfsight.git
cd alfsight

Install dependencies

terminal
pnpm install

This installs all workspace dependencies across the monorepo: apps/api, apps/dashboard, apps/widget, apps/web, packages/types, packages/validation, and database.

Environment variables

Copy the example env file and fill in your values:

terminal
cp .env.example .env

Root .env

VariableDescriptionDefault
API_BASE_URLURL of the API workerhttp://localhost:8787
VITE_API_BASE_URLAPI URL exposed to the dashboard frontendhttp://localhost:8787
OPENHANDLE_TEST_KEYOpenHandle test API key (for development)—
OPENHANDLE_LIVE_KEYOpenHandle live API key (for production)—

API secrets apps/api/.dev.vars

The API worker reads secrets from .dev.vars during local development (Wrangler convention). Create this file:

apps/api/.dev.vars
# OpenHandle — at least one key is required
OPENHANDLE_TEST_KEY=oh_test_your_key_here
OPENHANDLE_LIVE_KEY=oh_live_your_key_here

# Meta OAuth (optional, for Instagram account linking)
META_APP_ID=your_meta_app_id
META_APP_SECRET=your_meta_app_secret
META_REDIRECT_URI=http://localhost:8787/api/instagram/callback

# Environment
ENVIRONMENT=development
Important: Never commit .env or .dev.vars. Both are already in .gitignore. For production, use wrangler secret put <KEY> to set secrets securely.

Getting an OpenHandle API key

  1. Go to openhandle.dev
  2. Create an account and generate a Test key for development
  3. Add the key to both .env and apps/api/.dev.vars
  4. For production, generate a Live key and set it via wrangler secret put OPENHANDLE_LIVE_KEY

Cloudflare setup

The API runs on Cloudflare Workers with D1 (database) and KV (cache). You need to create these resources and update wrangler.jsonc.

1. Login to Wrangler

terminal
npx wrangler login

2. Create a D1 database

terminal
npx wrangler d1 create instagram-widget-db

Copy the returned database_id and paste it into apps/api/wrangler.jsonc:

apps/api/wrangler.jsonc
"d1_databases": [
  {
    "binding": "DB",
    "database_name": "instagram-widget-db",
    "database_id": "YOUR_DATABASE_ID"
  }
]

3. Create a KV namespace

terminal
npx wrangler kv namespace create FEED_CACHE

Copy the returned id and update wrangler.jsonc:

apps/api/wrangler.jsonc
"kv_namespaces": [
  {
    "binding": "FEED_CACHE",
    "id": "YOUR_KV_NAMESPACE_ID"
  }
]

Database & migrations

The project uses Drizzle ORM with Cloudflare D1 (SQLite). Migrations are generated from the schema at database/schema.ts.

Generate migrations (if schema changed)

terminal
pnpm db:generate

Apply migrations locally

terminal
# Apply each migration file in order
pnpm --filter @instagram-widget/api exec wrangler d1 execute \
  instagram-widget-db --local \
  --file=../../database/migrations/0000_bouncy_peter_parker.sql

pnpm --filter @instagram-widget/api exec wrangler d1 execute \
  instagram-widget-db --local \
  --file=../../database/migrations/0001_old_risque.sql

Apply migrations to production

terminal
# Same commands without --local
pnpm --filter @instagram-widget/api exec wrangler d1 execute \
  instagram-widget-db \
  --file=../../database/migrations/0000_bouncy_peter_parker.sql

Database schema

The D1 database has four tables:

TablePurpose
usersUser accounts (id, email)
instagram_accountsLinked Instagram accounts with OAuth tokens
feedsWidget configurations (name, JSON settings)
postsCached Instagram posts (media URL, caption, type, likes)

Running locally

terminal
# Run all apps in parallel
pnpm dev

# Or run individually
pnpm dev:api         # API       → http://localhost:8787
pnpm dev:dashboard   # Dashboard → http://localhost:5173
pnpm dev:widget      # Widget    → http://localhost:5174
pnpm dev:web         # Docs site → http://localhost:4321
Tip: The API uses Wrangler's local mode, which emulates D1 and KV in-memory. No Cloudflare account is needed for local development — just the API key in .dev.vars.

Dev auth shortcut

In development mode, the API accepts a x-dev-user-id header to simulate authentication without OAuth:

terminal
curl http://localhost:8787/api/feeds \
  -H "x-dev-user-id: dev-user-1"

Creating a widget

  1. Open the dashboard at http://localhost:5173
  2. Click Create widget (or press G)
  3. Enter an Instagram username and widget name
  4. The editor opens with a live preview — configure layout, columns, gap, radius, card style, hover effects, and background color
  5. Switch to the Deploy tab to get the embed snippet

Embedding

Add two lines to any HTML page:

HTML
<!-- Load the widget script -->
<script src="https://your-domain.com/instagram.js" defer></script>

<!-- Place the widget -->
<instagram-feed
  feed="YOUR_FEED_ID"
  api-base="https://your-api.workers.dev"
></instagram-feed>

The widget uses Shadow DOM for complete style isolation — it won't conflict with your page's CSS. Works with plain HTML, WordPress, Shopify, Webflow, React, Vue, Next.js, or any platform that renders HTML.

Widget attributes

AttributeRequiredDescription
feedYesThe feed ID from your dashboard
api-baseNoAPI URL. Defaults to the bundled origin. Required if widget is served from a different domain than the API.

Customization

All settings are stored in the feed's JSON settings and applied via CSS custom properties in the Shadow DOM:

SettingCSS variableDefaultDescription
Columns--ig-grid-columns4Number of columns (1–6)
Gap--ig-grid-gap14pxSpace between posts
Border radius--ig-border-radius6pxCorner radius of post cards
Background--ig-bg-colortransparentWidget container background color

Feed settings reference

PropertyTypeDescription
layout"grid" | "masonry" | "showcase"Feed layout mode
columnsnumberGrid columns (1–6)
rowsnumberGrid rows
gapnumberGap in pixels
borderRadiusnumberCorner radius in pixels
cardStyle"clean" | "rounded" | "elevated"Card appearance
hoverStyle"zoom" | "overlay" | "lift" | "none"Hover animation
showCaptionbooleanShow captions on hover
showHeaderbooleanShow profile header
backgroundColorstringContainer background (any CSS color)
postCountnumberMax posts to display
order"newest" | "oldest"Post sort order

API reference

Base URL: http://localhost:8787 (local) or your deployed Workers URL.

Public endpoints (no auth)

GET/api/health— Service health check
GET/api/feeds/:id— Fetch feed with posts (KV-cached)
GET/api/instagram/:identifier— Public profile lookup
GET/api/instagram/:identifier/posts— Paginated posts
GET/api/instagram/:identifier/reels— Paginated reels
GET/api/instagram/:identifier/stories— Current stories
GET/api/instagram/:identifier/highlights— Public highlights
GET/api/instagram/:identifier/followers— Paginated followers
GET/api/instagram/:identifier/following— Paginated following

Authenticated endpoints

GET/api/feeds— List all feeds for current user
POST/api/feeds— Create a new feed
PATCH/api/feeds/:id— Update feed settings
DELETE/api/feeds/:id— Delete a feed
POST/api/feeds/:id/refresh— Trigger manual sync
POST/api/instagram/connect— Start OAuth flow
GET/api/instagram/callback— OAuth redirect callback
GET/api/instagram/accounts— List linked accounts

Examples

Fetch a feed the same way the widget does. This route is public and served from the KV cache:

terminal
curl http://localhost:8787/api/feeds/FEED_ID
200 OK
{
  "id": "a1b2c3d4e5f6g7h8",
  "name": "Homepage feed",
  "settings": {
    "instagramHandle": "natgeo",
    "layout": "grid",
    "columns": 4,
    "rows": 2,
    "gap": 16,
    "borderRadius": 8
  },
  "profile": {
    "name": "National Geographic",
    "username": "natgeo",
    "avatarUrl": "https://…/avatar.jpg",
    "followUrl": "https://instagram.com/natgeo",
    "posts": 31000,
    "followers": 280000000,
    "following": 150
  },
  "posts": [
    {
      "id": "3412…",
      "type": "IMAGE",
      "imageUrl": "https://…/post.jpg",
      "thumbnailUrl": null,
      "permalink": "https://instagram.com/p/…",
      "caption": "Into the wild",
      "likeCount": 120394,
      "timestamp": "2026-10-20T14:32:00.000Z"
    }
  ]
}

Create a feed for a public Instagram profile. Posts sync right away:

terminal
curl -X POST http://localhost:8787/api/feeds \
  -H "Content-Type: application/json" \
  -H "x-dev-user-id: dev-user-1" \
  -d '{
    "name": "Homepage feed",
    "settings": { "instagramHandle": "natgeo", "layout": "grid", "columns": 4 }
  }'
201 Created
{
  "id": "a1b2c3d4e5f6g7h8",
  "name": "Homepage feed",
  "instagramAccountId": null,
  "settings": { "instagramHandle": "natgeo", "layout": "grid", "columns": 4 },
  "createdAt": "2026-10-27T09:00:00.000Z",
  "updatedAt": "2026-10-27T09:00:00.000Z"
}

Update settings from your own code. Send only the fields you want to change:

update-feed.ts
const res = await fetch(`${API_BASE}/api/feeds/${feedId}`, {
  method: "PATCH",
  headers: {
    "Content-Type": "application/json",
    "x-dev-user-id": "dev-user-1",
  },
  body: JSON.stringify({
    settings: { layout: "masonry", gap: 8, hoverStyle: "overlay" },
  }),
});

if (!res.ok) {
  const { error } = await res.json();
  throw new Error(`${error.code}: ${error.message}`);
}

Trigger a manual sync:

terminal
curl -X POST http://localhost:8787/api/feeds/FEED_ID/refresh \
  -H "x-dev-user-id: dev-user-1"
200 OK
{ "message": "Feed refreshed successfully", "syncedPosts": 12 }

Errors

Every error uses the same shape, with a machine-readable code:

404 Not Found
{
  "error": {
    "code": "FEED_NOT_FOUND",
    "message": "Feed not found"
  }
}

Authentication

In development, pass the dev header to simulate a user:

HTTP header
x-dev-user-id: dev-user-1

The dashboard sends this header automatically in development mode. In production, the dev header is ignored and authenticated routes require Authorization: Bearer <ADMIN_TOKEN>. The dashboard asks for this token on its sign-in page.

Production deploy

Everything runs on Cloudflare: the API is a Worker, and the widget, dashboard and this site are Cloudflare Pages projects. All commands run from the repo root.

1. Log in and create the database and cache

terminal
pnpm wrangler login
pnpm wrangler d1 create instagram-widget-db
pnpm wrangler kv namespace create FEED_CACHE

Paste the printed database_id and KV id into apps/api/wrangler.jsonc, then create the tables:

terminal
pnpm db:migrate:remote

2. Set secrets

terminal
# Dashboard sign-in token (generate a long random value)
openssl rand -hex 32
pnpm wrangler secret put ADMIN_TOKEN --config apps/api/wrangler.jsonc

# OpenHandle key used to fetch public Instagram data
pnpm wrangler secret put OPENHANDLE_LIVE_KEY --config apps/api/wrangler.jsonc

3. Deploy the API and widget

terminal
pnpm deploy:api     # → https://instagram-widget-api.<you>.workers.dev
pnpm deploy:widget  # → https://alfsight-widget.pages.dev

Cron triggers (0 */6 * * *) activate on deploy and re-sync every feed every 6 hours.

4. Deploy the dashboard

Point the dashboard at your API and widget URLs, then deploy:

apps/dashboard/.env.production
PUBLIC_API_BASE=https://instagram-widget-api.<you>.workers.dev
PUBLIC_WIDGET_URL=https://alfsight-widget.pages.dev
terminal
pnpm deploy:dashboard  # → https://alfsight-dashboard.pages.dev

Open the dashboard and sign in with your ADMIN_TOKEN. The Install button now copies a production snippet that loads instagram.iife.js from your widget project.

5. Deploy this site (optional)

apps/web/.env.production
PUBLIC_DASHBOARD_URL=https://alfsight-dashboard.pages.dev
terminal
pnpm deploy:web  # → https://alfsight.pages.dev

If a *.pages.dev name is taken, Cloudflare gives the project a different subdomain. Use the URL the deploy prints in the env files above. Custom domains can be added under Pages → Custom domains.

Project structure

tree
alfsight/
├── apps/
│   ├── api/              # Cloudflare Workers API (Hono + Drizzle)
│   │   ├── src/
│   │   │   ├── index.ts         # Entry point, routes, middleware
│   │   │   ├── middleware/      # Auth, services injection
│   │   │   ├── routes/          # feeds, instagram, health
│   │   │   ├── services/        # Feed, cache, Instagram providers
│   │   │   └── types/           # Env bindings, app variables
│   │   ├── wrangler.jsonc       # D1, KV, cron config
│   │   └── .dev.vars            # Local secrets (gitignored)
│   │
│   ├── dashboard/        # Admin UI (Astro + React + Tailwind v4)
│   │   └── src/
│   │       ├── pages/           # index, settings, widgets/[id]
│   │       ├── components/      # Sidebar, WidgetEditor, WidgetsGrid
│   │       ├── layouts/         # DashboardLayout
│   │       └── styles/          # global.css (theme vars)
│   │
│   ├── widget/           # Embeddable Web Component (< 6 kB)
│   │   └── src/
│   │       ├── widget.ts        # <instagram-feed> custom element
│   │       ├── styles.ts        # Shadow DOM styles
│   │       └── api.ts           # Feed fetch client
│   │
│   └── web/              # Landing page & docs (Astro)
│       └── src/
│           ├── pages/           # index, docs
│           ├── components/      # Navbar, Hero, Features, etc.
│           └── layouts/         # BaseLayout
│
├── packages/
│   ├── types/            # Shared TypeScript interfaces
│   └── validation/       # Shared Zod schemas
│
├── database/
│   ├── schema.ts         # Drizzle ORM schema (users, feeds, posts)
│   └── migrations/       # Auto-generated SQL files
│
├── .env.example          # Template for root env
├── pnpm-workspace.yaml   # Workspace config
└── package.json          # Root scripts (dev, build, db)