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.
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:
# 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 alfsightIf you just want to try it without forking:
git clone https://github.com/Manish-Tamang/alfsight.git
cd alfsightInstall dependencies
pnpm installThis 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:
cp .env.example .envRoot .env
| Variable | Description | Default |
|---|---|---|
API_BASE_URL | URL of the API worker | http://localhost:8787 |
VITE_API_BASE_URL | API URL exposed to the dashboard frontend | http://localhost:8787 |
OPENHANDLE_TEST_KEY | OpenHandle test API key (for development) | — |
OPENHANDLE_LIVE_KEY | OpenHandle 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:
# 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.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
- Go to openhandle.dev
- Create an account and generate a Test key for development
- Add the key to both
.envandapps/api/.dev.vars - 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
npx wrangler login2. Create a D1 database
npx wrangler d1 create instagram-widget-dbCopy the returned database_id and paste it into apps/api/wrangler.jsonc:
"d1_databases": [
{
"binding": "DB",
"database_name": "instagram-widget-db",
"database_id": "YOUR_DATABASE_ID"
}
]3. Create a KV namespace
npx wrangler kv namespace create FEED_CACHECopy the returned id and update 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)
pnpm db:generateApply migrations locally
# 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.sqlApply migrations to production
# Same commands without --local
pnpm --filter @instagram-widget/api exec wrangler d1 execute \
instagram-widget-db \
--file=../../database/migrations/0000_bouncy_peter_parker.sqlDatabase schema
The D1 database has four tables:
| Table | Purpose |
|---|---|
users | User accounts (id, email) |
instagram_accounts | Linked Instagram accounts with OAuth tokens |
feeds | Widget configurations (name, JSON settings) |
posts | Cached Instagram posts (media URL, caption, type, likes) |
Running locally
# 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.dev.vars.Dev auth shortcut
In development mode, the API accepts a x-dev-user-id header to simulate authentication without OAuth:
curl http://localhost:8787/api/feeds \
-H "x-dev-user-id: dev-user-1"Creating a widget
- Open the dashboard at
http://localhost:5173 - Click Create widget (or press G)
- Enter an Instagram username and widget name
- The editor opens with a live preview — configure layout, columns, gap, radius, card style, hover effects, and background color
- Switch to the Deploy tab to get the embed snippet
Embedding
Add two lines to any HTML page:
<!-- 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
| Attribute | Required | Description |
|---|---|---|
feed | Yes | The feed ID from your dashboard |
api-base | No | API 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:
| Setting | CSS variable | Default | Description |
|---|---|---|---|
| Columns | --ig-grid-columns | 4 | Number of columns (1–6) |
| Gap | --ig-grid-gap | 14px | Space between posts |
| Border radius | --ig-border-radius | 6px | Corner radius of post cards |
| Background | --ig-bg-color | transparent | Widget container background color |
Feed settings reference
| Property | Type | Description |
|---|---|---|
layout | "grid" | "masonry" | "showcase" | Feed layout mode |
columns | number | Grid columns (1–6) |
rows | number | Grid rows |
gap | number | Gap in pixels |
borderRadius | number | Corner radius in pixels |
cardStyle | "clean" | "rounded" | "elevated" | Card appearance |
hoverStyle | "zoom" | "overlay" | "lift" | "none" | Hover animation |
showCaption | boolean | Show captions on hover |
showHeader | boolean | Show profile header |
backgroundColor | string | Container background (any CSS color) |
postCount | number | Max 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)
/api/health— Service health check/api/feeds/:id— Fetch feed with posts (KV-cached)/api/instagram/:identifier— Public profile lookup/api/instagram/:identifier/posts— Paginated posts/api/instagram/:identifier/reels— Paginated reels/api/instagram/:identifier/stories— Current stories/api/instagram/:identifier/highlights— Public highlights/api/instagram/:identifier/followers— Paginated followers/api/instagram/:identifier/following— Paginated followingAuthenticated endpoints
/api/feeds— List all feeds for current user/api/feeds— Create a new feed/api/feeds/:id— Update feed settings/api/feeds/:id— Delete a feed/api/feeds/:id/refresh— Trigger manual sync/api/instagram/connect— Start OAuth flow/api/instagram/callback— OAuth redirect callback/api/instagram/accounts— List linked accountsExamples
Fetch a feed the same way the widget does. This route is public and served from the KV cache:
curl http://localhost:8787/api/feeds/FEED_ID{
"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:
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 }
}'{
"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:
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:
curl -X POST http://localhost:8787/api/feeds/FEED_ID/refresh \
-H "x-dev-user-id: dev-user-1"{ "message": "Feed refreshed successfully", "syncedPosts": 12 }Errors
Every error uses the same shape, with a machine-readable code:
{
"error": {
"code": "FEED_NOT_FOUND",
"message": "Feed not found"
}
}Authentication
In development, pass the dev header to simulate a user:
x-dev-user-id: dev-user-1The 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
pnpm wrangler login
pnpm wrangler d1 create instagram-widget-db
pnpm wrangler kv namespace create FEED_CACHEPaste the printed database_id and KV id into apps/api/wrangler.jsonc, then create the tables:
pnpm db:migrate:remote2. Set secrets
# 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.jsonc3. Deploy the API and widget
pnpm deploy:api # → https://instagram-widget-api.<you>.workers.dev
pnpm deploy:widget # → https://alfsight-widget.pages.devCron 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:
PUBLIC_API_BASE=https://instagram-widget-api.<you>.workers.dev
PUBLIC_WIDGET_URL=https://alfsight-widget.pages.devpnpm deploy:dashboard # → https://alfsight-dashboard.pages.devOpen 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)
PUBLIC_DASHBOARD_URL=https://alfsight-dashboard.pages.devpnpm deploy:web # → https://alfsight.pages.devIf 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
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)