Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 7 additions & 13 deletions .github/workflows/pull_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ on:
pull_request:

jobs:
lint:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
Expand All @@ -16,18 +16,12 @@ jobs:
node-version: 24
cache: npm

- run: npm ci
- run: npm run check

typecheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Setup env files for type generation
run: |
for dir in examples/*/; do
[ -f "$dir/.env.example" ] && cp "$dir/.env.example" "$dir/.env"
done

- run: npm ci
- run: npm run lint:check
- run: npm run typecheck
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ dist/

.env
.env.*
.dev.vars
!.env.example

20 changes: 20 additions & 0 deletions examples/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Cloudflare Workers
.wrangler/
worker-configuration.d.ts

# Lock files
package-lock.json
bun.lock
yarn.lock
pnpm-lock.yaml

# Logs
logs
*.log

# Build output
dist/
build/
.out/
.cache/
.next/
5 changes: 5 additions & 0 deletions examples/notion-automations-sync/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FRAMER_PROJECT_URL="https://framer.com/projects/YourProject--xxxxxxxxxxxxxxxx"
FRAMER_API_KEY="your-framer-api-key-here"
FRAMER_COLLECTION_NAME="Content Items"
NOTION_DATABASE_ID="your-notion-database-id-here"
WEBHOOK_TOKEN="your-webhook-secret-token-here"
88 changes: 88 additions & 0 deletions examples/notion-automations-sync/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# Notion → Framer Sync

Cloudflare Worker that syncs a Notion database to a Framer CMS collection.

## How it works

Uses `framer-api` with the `using` keyword for automatic resource cleanup:

```ts
using framer = await connect(projectUrl, apiKey);
const collections = await framer.getManagedCollections();
// connection automatically closed when scope exits
```

## Setup

1. `npm install`
2. `cp .env.example .env` and fill in your values
3. `npm run setup` - creates Framer collection (one-time)

## Local Development

```bash
npm run dev
```

To test with Notion webhooks, expose your local server:

```bash
cloudflared tunnel --url http://localhost:8787
```

## Deploy

```bash
wrangler secret bulk .env
npm run deploy
```

Your worker URL will be printed after deploy.

## Notion Automation Setup

1. Add "Deleted" checkbox property (for soft-deletes)
2. Click ⚡ → New automation
3. Trigger: "When page added" or "When property edited"
4. Action: "Send webhook"
- URL: your worker URL
- Headers: `Authorization: <your WEBHOOK_TOKEN>`

Repeat for each trigger type you need.

## Config

Edit `src/config.ts`:

- `TOMBSTONE_PROPERTY` - checkbox property for soft-delete
- `FIELD_MAPPING` - maps Notion properties to Framer fields

## Notion Automations vs REST API

This example uses Notion Automations (webhook actions), not the Notion API.

| | Automation Webhooks | REST API + Webhooks |
|---|---|---|
| Setup | UI-based, per-database | Programmatic integration |
| Payload | Full page properties | Webhook sends IDs only, must fetch |
| Auth | Custom header (optional) | OAuth / integration token |
| Triggers | Page add, property edit, button | Subscribe to events programmatically |
| Rate limits | Max 5 webhooks per automation | 3 req/sec |
| Page content | Properties only | Full blocks access |
| Bulk sync | Not supported | Query database endpoint |
| Plan | Paid plans only | Free tier available |

When to use Automations:
- Simple property sync
- No initial bulk import needed
- UI-based configuration preferred

When to use REST API:
- Need page content (blocks)
- Bulk/initial sync required
- Free tier
- Multiple databases from one integration

Note: Notion's webhook features are evolving. Verify current capabilities in the official docs.

Sources: [Notion Webhook Actions](https://www.notion.com/help/webhook-actions), [Notion API Webhooks](https://developers.notion.com/reference/webhooks)
24 changes: 24 additions & 0 deletions examples/notion-automations-sync/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "notion-framer-sync",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"cf-typegen": "wrangler types",
"postinstall": "wrangler types",
"setup": "node --experimental-strip-types scripts/setup.ts",
"typecheck": "tsc --noEmit -p scripts && tsc --noEmit"
},
"dependencies": {
"@notionhq/client": "^5.6.0",
"framer-api": "beta"
},
"devDependencies": {
"@types/node": "^24.10.0",
"typescript": "^5.5.2",
"wrangler": "4.57.0"
}
}
56 changes: 56 additions & 0 deletions examples/notion-automations-sync/scripts/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env node --strip-types
import assert from "node:assert";
import { connect, type ManagedCollection } from "framer-api";
import { config } from "../src/config";

if (process.loadEnvFile) {
process.loadEnvFile(".env");
}

const projectUrl = process.env.FRAMER_PROJECT_URL;
const apiKey = process.env.FRAMER_API_KEY;
const collectionName = process.env.FRAMER_COLLECTION_NAME;

assert(projectUrl, "FRAMER_PROJECT_URL required");
assert(apiKey, "FRAMER_API_KEY required");
assert(collectionName, "FRAMER_COLLECTION_NAME required");

using framer = await connect(projectUrl, apiKey);

async function findOrCreateCollection(name: string) {
const existingCollections = await framer.getManagedCollections();
const existing = existingCollections.find((c) => c.name === name);

if (existing) {
console.log(`Found existing collection [id: ${existing.id}]`);
return existing;
}

const collection = await framer.createManagedCollection(name);
console.log(`Created collection [id: ${collection.id}]`);
return collection;
}

async function setupFields(collection: ManagedCollection) {
const fields = config.FIELD_MAPPING.map((mapping) => ({
type: mapping.type,
name: mapping.framerName,
id: mapping.framerId,
}));

await collection.setFields(fields);

const setFields = await collection.getFields();
console.log(`Set ${setFields.length} fields`);
}

async function logCollectionStatus(collection: ManagedCollection) {
const itemIds = await collection.getItemIds();
console.log(`Collection ready [existing items: ${itemIds.length}]`);
}

const collection = await findOrCreateCollection(collectionName);
await setupFields(collection);
await logCollectionStatus(collection);

console.log("\n✅ Setup complete! Collection is ready for webhook integration.");
14 changes: 14 additions & 0 deletions examples/notion-automations-sync/scripts/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true
},
"include": ["**/*.ts"]
}
50 changes: 50 additions & 0 deletions examples/notion-automations-sync/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
export type FieldMapping = {
notionProperty: string;
framerId: string;
framerName: string;
type: "string" | "number" | "boolean" | "date";
};

type Config = {
AUTO_PUBLISH: boolean;
TOMBSTONE_PROPERTY: string;
FIELD_MAPPING: FieldMapping[];
};

export const config: Config = {
AUTO_PUBLISH: true,
TOMBSTONE_PROPERTY: "Deleted",

FIELD_MAPPING: [
{
notionProperty: "Title",
framerId: "title",
framerName: "Title",
type: "string",
},
{
notionProperty: "Description",
framerId: "description",
framerName: "Description",
type: "string",
},
{
notionProperty: "Status",
framerId: "status",
framerName: "Status",
type: "string",
},
{
notionProperty: "Created",
framerId: "created",
framerName: "Created",
type: "date",
},
{
notionProperty: "Priority",
framerId: "priority",
framerName: "Priority",
type: "number",
},
],
};
78 changes: 78 additions & 0 deletions examples/notion-automations-sync/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { connect } from "framer-api";
import { config } from "./config";
import { extractFieldData, isDeleted, type NotionAutomationPayload } from "./notion";

async function handleWebhook(request: Request, env: Env): Promise<Response> {
if (request.method !== "POST") {
return new Response("Method Not Allowed", { status: 405 });
}

const token = request.headers.get("Authorization");
if (token !== env.WEBHOOK_TOKEN) {
return new Response("Unauthorized", { status: 401 });
}

const payload = await request.json<NotionAutomationPayload>();

try {
const pageId = payload.data.id.replace(/-/gu, "");

if (payload.data.in_trash || payload.data.archived) {
return json({ success: true, action: "skipped", reason: "trashed or archived" });
}

const parent = payload.data.parent;
if (env.NOTION_DATABASE_ID && "database_id" in parent && parent.database_id) {
const normalize = (id: string) => id.replace(/-/gu, "");
if (normalize(parent.database_id) !== normalize(env.NOTION_DATABASE_ID)) {
return json({ skipped: true, reason: "Different database" });
}
}

using framer = await connect(env.FRAMER_PROJECT_URL, env.FRAMER_API_KEY);

const collections = await framer.getManagedCollections();
const collection = collections.find((c) => c.name === env.FRAMER_COLLECTION_NAME);

if (!collection) {
return json(
{
error: `${env.FRAMER_COLLECTION_NAME} collection not found`,
available: collections.map((c) => c.name),
},
404,
);
}

if (isDeleted(payload.data.properties, config.TOMBSTONE_PROPERTY)) {
await collection.removeItems([pageId]);
await publishAndDeploy(framer);
console.log(`Deleted page ${pageId}`);
return json({ success: true, action: "deleted", id: pageId, published: config.AUTO_PUBLISH });
}

const fieldData = extractFieldData(payload.data.properties, config.FIELD_MAPPING);
const slug = `item-${pageId}`;

await collection.addItems([{ id: pageId, slug, fieldData }]);
await publishAndDeploy(framer);
console.log(`Upserted page ${pageId} → ${slug}`);

return json({ success: true, action: "upserted", id: pageId, slug, published: config.AUTO_PUBLISH });
} catch (error) {
console.error(`Error processing page:`, error);
return json({ error: error instanceof Error ? error.message : "Unknown error" }, 500);
}
}

async function publishAndDeploy(framer: Awaited<ReturnType<typeof connect>>) {
if (!config.AUTO_PUBLISH) return null;
const { deployment } = await framer.publish();
const hostnames = await framer.deploy(deployment.id);
return { deploymentId: deployment.id, hostnames };
}

const json = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), { status, headers: { "Content-Type": "application/json" } });

export default { fetch: handleWebhook } satisfies ExportedHandler<Env>;
Loading