DEV Community

RAXXO Studios
RAXXO Studios

Posted on • Originally published at raxxo.shop

From Notion to Shopify Pages via API: A 50-Line Sync Script

  • A 50-line TypeScript script syncs Notion database pages to Shopify Pages via the Admin REST API

  • Uses notion-to-md to turn Notion blocks into clean HTML body, then POSTs to /admin/api/2026-01/pages.json

  • template_suffix is mandatory, omit it and Shopify renders your About template by accident

  • GitHub Actions runs the sync every 6 hours so editors keep working in Notion while raxxo.shop stays current

  • Real gotchas: handle conflicts on rename, image URLs from Notion's CDN expire after one hour

I keep my long-form drafts in Notion because the editor is the best one for actually writing. I keep my live pages on Shopify because that is where my customers land. For two years I copy-pasted between them and pretended that was fine. It was not fine.

Last weekend I built a 50-line sync script. Notion is the source of truth. Shopify is the render target. The cron runs every six hours. I stopped touching the Shopify admin for content edits, and nothing has broken.

Here is the whole script, plus the three things that nearly broke it.

The Notion side: integration token and database query

Notion API access is one internal integration plus one shared database. You create the integration at notion.so/my-integrations, copy the token (starts with ntn_ for new tokens, secret_ for older ones), and share your database with the integration from inside Notion. If you skip the share step the API returns 404 and you will spend ten minutes thinking your token is wrong. It is not, you just forgot to invite the bot.

The database needs at least three properties: a Title, a checkbox called Published, and a rich-text or formula property called Slug. Mine also has Tags and Updated, which I use for the Shopify tags array and the conflict check.

The query is one call. I filter for Published equals true and sort by Updated descending so the most recently edited page goes first.


import { Client } from "@notionhq/client";

const notion = new Client({ auth: process.env.NOTION_TOKEN });

const pages = await notion.databases.query({
  database_id: process.env.NOTION_DB_ID!,
  filter: { property: "Published", checkbox: { equals: true } },
  sorts: [{ property: "Updated", direction: "descending" }],
});

Enter fullscreen mode Exit fullscreen mode

That is the entire reader. Notion paginates at 100 results, which for me is plenty. If you have more, add a start_cursor loop, but most blogs do not need it. We covered why that constraint is liberating in Why I Replaced Notion With 40 Markdown Files, if you want the broader argument for treating Notion as a content layer instead of a system of record.

Block to HTML: notion-to-md is the only sane option

Notion blocks are JSON. Shopify Pages accepts body_html. You need a converter. I tried writing my own. After two days of partial table support and broken nested lists, I stopped pretending and installed notion-to-md.


import { NotionToMarkdown } from "notion-to-md";
import { marked } from "marked";

const n2m = new NotionToMarkdown({ notionClient: notion });

async function pageToHtml(pageId: string) {
  const blocks = await n2m.pageToMarkdown(pageId);
  const md = n2m.toMarkdownString(blocks).parent;
  return marked.parse(md);
}

Enter fullscreen mode Exit fullscreen mode

That is it. Five lines of conversion and it handles headings, bullet lists, numbered lists, code blocks with language hints, callouts, quotes, dividers, embedded images, and inline formatting. The output is plain HTML. No React, no MDX, no custom renderer. Shopify Pages accepts it directly.

There is one caveat that bit me on day one. Image URLs in the Notion API response are signed S3 URLs from prod-files-secure.s3.us-west-2.amazonaws.com, and they expire one hour after the API returns them. If you POST that HTML to Shopify and a customer hits the page two hours later, the image is broken.

The fix is to detect Notion image URLs in the converted HTML, fetch each one, upload it to Shopify Files via the GraphQL stagedUploadsCreate mutation, then string-replace the expiring URL with the permanent CDN URL. I cover that pattern end-to-end in Build a Magnific to Shopify Image Pipeline in 5 Minutes, which is the same staged-upload flow with a different source.

If you skip the rehosting step, your sync works for one hour after every run. So either rehost, or run the cron every 50 minutes and pretend.

The Shopify side: POST to pages.json with template_suffix

Shopify Pages live at /admin/api/2026-01/pages.json. POST creates, PUT updates. The body is small.


async function upsertPage(p: {
  title: string;
  handle: string;
  body_html: string;
  template_suffix: string;
}) {
  const existing = await fetch(
    `https://${SHOP}/admin/api/2026-01/pages.json?handle=${p.handle}`,
    { headers: { "X-Shopify-Access-Token": TOKEN } }
  ).then(r => r.json());

  const id = existing.pages?.[0]?.id;
  const url = id
    ? `https://${SHOP}/admin/api/2026-01/pages/${id}.json`
    : `https://${SHOP}/admin/api/2026-01/pages.json`;

  return fetch(url, {
    method: id ? "PUT" : "POST",
    headers: {
      "X-Shopify-Access-Token": TOKEN,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ page: p }),
  }).then(r => r.json());
}

Enter fullscreen mode Exit fullscreen mode

The one detail that will eat a day of your life if you miss it: template_suffix is mandatory. Always. If you POST a page without it, Shopify silently uses the default page template, which on my theme is the About page with its big hero image. Every single Notion sync became a clone of the About page until I figured this out.

I now hard-code template_suffix: "doc" in the create call and crash the script if it is missing. There is a pre-tool hook in this repo that blocks any Shopify POST or PUT without template_suffix set. If you are wiring this up by hand, write the same check.

The other field worth setting explicitly is published. Shopify defaults pages to draft. Pass published: true in the body or your editors will be confused why their work is not visible.

Putting it together: the actual 50 lines

Here is the full script with the rehost step replaced by a comment, since that block is its own 80 lines.


import { Client } from "@notionhq/client";
import { NotionToMarkdown } from "notion-to-md";
import { marked } from "marked";

const NOTION_TOKEN = process.env.NOTION_TOKEN!;
const NOTION_DB = process.env.NOTION_DB_ID!;
const SHOP = process.env.SHOPIFY_SHOP!;
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN!;

const notion = new Client({ auth: NOTION_TOKEN });
const n2m = new NotionToMarkdown({ notionClient: notion });

const prop = (page: any, name: string) =>
  page.properties[name]?.title?.[0]?.plain_text ??
  page.properties[name]?.rich_text?.[0]?.plain_text ?? "";

async function syncOne(page: any) {
  const title = prop(page, "Title");
  const handle = prop(page, "Slug");
  if (!title || !handle) return;

  const md = n2m.toMarkdownString(await n2m.pageToMarkdown(page.id)).parent;
  let body_html = await marked.parse(md);
  // body_html = await rehostNotionImages(body_html);

  const existing = await fetch(
    `https://${SHOP}/admin/api/2026-01/pages.json?handle=${handle}`,
    { headers: { "X-Shopify-Access-Token": TOKEN } },
  ).then(r => r.json());

  const id = existing.pages?.[0]?.id;
  const payload = {
    page: { title, handle, body_html, template_suffix: "doc", published: true },
  };

  await fetch(
    id
      ? `https://${SHOP}/admin/api/2026-01/pages/${id}.json`
      : `https://${SHOP}/admin/api/2026-01/pages.json`,
    {
      method: id ? "PUT" : "POST",
      headers: { "X-Shopify-Access-Token": TOKEN, "Content-Type": "application/json" },
      body: JSON.stringify(payload),
    },
  );
}

const { results } = await notion.databases.query({
  database_id: NOTION_DB,
  filter: { property: "Published", checkbox: { equals: true } },
});

for (const page of results) await syncOne(page);

Enter fullscreen mode Exit fullscreen mode

Run with tsx sync.ts. First run, every published page lands on Shopify. Subsequent runs, only edited pages change because the upsert path matches by handle. If you hate the for-of loop, swap it for Promise.all, but Shopify rate-limits at 2 requests per second on REST, so I keep it serial.

The cron: GitHub Actions every six hours

The script runs locally with the same env vars during dev. In production it lives in a one-file GitHub Actions workflow that fires every six hours.


name: notion-shopify-sync
on:
  schedule: [{ cron: "0 */6 * * *" }]
  workflow_dispatch:
jobs:
  sync:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: oven-sh/setup-bun@v2
      - run: bun install
      - run: bun run sync.ts
        env:
          NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
          NOTION_DB_ID: ${{ secrets.NOTION_DB_ID }}
          SHOPIFY_SHOP: ${{ secrets.SHOPIFY_SHOP }}
          SHOPIFY_ADMIN_TOKEN: ${{ secrets.SHOPIFY_ADMIN_TOKEN }}

Enter fullscreen mode Exit fullscreen mode

Six hours is my number. Yours might be one hour, or one minute. Pick what fits your editorial rhythm. I tried 15-minute syncs at first and immediately hit the Notion rate limit when the rehost step kicked in.

A few more honest gotchas:

Handle conflicts. If an editor renames a Notion page, the slug changes, and the upsert creates a new Shopify page instead of updating the old one. The old page is now an orphan with stale content. I solved this by storing the Shopify page ID back into a Notion property after first publish. The next run reads that ID and PUTs to it directly, so renames update in place.

Deletes. The script never deletes. If a Notion page is unpublished, the Shopify page stays. That is intentional for me. If you want hard deletes, query Shopify for all pages with template_suffix: "doc", diff against the Notion result set, and DELETE the missing ones. I would not run that with workflow_dispatch only, because one bad query and your whole docs site is gone.

Drafts. The Published checkbox in Notion is the gate. Unchecking it stops new syncs but does not unpublish on Shopify. I add a separate published: false PUT on uncheck if I need that, which I usually do not.

Bottom line

Fifty lines of TypeScript replaced six hours of monthly copy-paste, and the editors I work with do not even know Shopify exists anymore. If you are doing the same dance between any source-of-truth tool and any render target, the pattern is the same: query, convert, upsert, schedule.

For more posts on this, the RAXXO Lab Overview groups every Tutorials and Shopify Dev article by topic. If you want the working repo for this script with the image rehost step included, it ships with my Shopify utilities at raxxo.shop/pages/claude-blueprint. Steal it, swap the field names for yours, and call it a Sunday.

Top comments (0)