nooxy
v2.0.0
Published
Zero dependency Notion reverse proxy for custom domains with seamless in-domain navigation for inner Notion pages, full HTML/CSS/JS customization, multi-instance support, CLI tools, and local development support. Built for Cloudflare Workers and modern No
Maintainers
Readme
Nooxy
The Problem
You built something great in Notion. Now you want to share it with the world on your own domain.
Your options today:
| Solution | Cost | SEO | Customization | Interactivity | | ---------------- | --------- | --------------------------------------- | ------------- | ------------- | | Notion Sites | $10-22/mo | Limited, noindex issues | Minimal | Full | | Super.so | $12-28/mo | Good, but subdomain-only hurts rankings | Good | Lost (static) | | Fruition | Free | Poor, outdated | Limited | Full | | Nooxy | Free | Full SEO suite | Complete | Full |
Notion Sites charges $10/month per domain with limited SEO and customization. Super.so costs $12-28/month and converts your pages to static HTML—you lose Notion's live databases, filtering, and real-time updates. Fruition is no longer maintained and lacks modern SEO features.
Nooxy gives you everything. For free.
See It Live
These sites run on Nooxy right now:
- os.draphy.org — Life documented as a file system
- draphy.org — Personal site
- em-ucd.com — Portfolio
View source, check the SEO tags, test the interactivity. It works.
Using Nooxy? Share your site in Discussions — we'd love to see it!
Why Nooxy?
What you get
- $0/month — No subscriptions, no limits
- Full SEO — Canonical URLs, structured data, proper indexing
- Complete control — Inject any CSS, JavaScript, HTML
- Live Notion — Real-time databases, filtering, collaboration
- Your domain — Professional URLs like
yourdomain.com/about
What you avoid
- ~~$120-336/year~~ on hosting fees
- ~~Subdomain SEO penalties~~ from free tiers
- ~~Static pages~~ that lose Notion's power
- ~~Vendor lock-in~~ from closed platforms
- ~~Outdated tools~~ that break with Notion updates
Features
SEO That Actually Works
Nooxy rewrites Notion's HTML to give search engines exactly what they need:
- Removes
noindextags — Your pages get indexed by Google - Canonical URLs —
https://yourdomain.com/aboutnot/About-abc123def - Structured data — JSON-LD schema for rich search results
- Open Graph & Twitter Cards — Beautiful social media previews
- Custom meta tags — Title, description, keywords, author per page
- AI attribution — Proper source credits for ChatGPT, Claude, Perplexity
- XML sitemap — Auto-generated at
/sitemap.xml - Robots.txt — Proper crawler directives at
/robots.txt
Complete Customization
- Custom CSS — Override any Notion style, add your brand
- JavaScript injection — Analytics, interactions, custom functionality
- HTML headers — Navigation bars, announcements, CTAs
- Google Fonts — Apply any font family site-wide
- Google Analytics — Built-in GA4 support
Production Ready
- Zero dependencies — Nothing to break, nothing to update
- Edge computing — Runs on Cloudflare Workers' global network
- Node.js support — Works with any modern Node.js runtime (22+)
- Multi-tenant — Host multiple sites from one deployment
- Local development — Test locally before deploying
Developer Experience
- TypeScript — Full type safety and IntelliSense
- CLI tools —
npx nooxy initandnpx nooxy generate - Auto-minification — CSS, JS, HTML optimized automatically
- Clean URLs —
/aboutinstead of/About-Page-abc123def456
Quick Start
New to this? If you don't have a project set up yet, start with our Full Deployment Guides instead — they walk you through everything from creating an account to deploying your live site. Currently available for Cloudflare Workers (recommended, ~15 min).
This section shows how to add Nooxy to an existing JavaScript/TypeScript project. It works with Cloudflare Workers, Node.js, Deno, Bun, or any runtime that supports the Fetch API.
For all configuration options and customization, see:
- Configuration Reference — all config options
- Project Files — CSS, JavaScript, and HTML injection
- CLI Commands — available commands
1. Install
In your project folder (where package.json is), run:
npm install nooxy2. Initialize
npx nooxy initThis creates a nooxy/ folder with all configuration files:
nooxy/
├── config.js # Main configuration
├── head.css # Custom CSS (optional)
├── head.js # JavaScript for <head> (optional)
├── body.js # JavaScript for <body> (optional)
└── header.html # Custom header HTML (optional)3. Get Your Notion Page IDs
Every Notion page has a unique Page ID — a 32-character code that identifies it.
How to find it:
- Open your Notion page in a browser
- Look at the URL:
https://www.notion.so/My-Page-Title-abc123def456789012345678901234ab ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ This is your Page ID (32 characters) - Copy just the ID part (after the last hyphen)
Examples:
| URL | Page ID |
| --------------------------------------------------------------- | ---------------------------------- |
| notion.so/Home-abc123def456789012345678901234ab | abc123def456789012345678901234ab |
| myworkspace.notion.site/Blog-11122233344455566677788899900aaa | 11122233344455566677788899900aaa |
Important: Make sure your Notion pages are published to web (Share → Publish → Publish to web).
4. Configure
Edit nooxy/config.js:
export const SITE_CONFIG = {
// Your custom domain (without https://)
domain: 'yourdomain.com',
// Your Notion workspace domain
// Find it in your Notion URL: https://YOUR-WORKSPACE.notion.site/...
notionDomain: 'yourworkspace.notion.site',
// Site name (appears in browser tabs and search results)
siteName: 'Your Site Name',
// Map URL paths to Notion page IDs
// Left side: URL on your site
// Right side: Notion page ID (32 characters)
slugToPage: {
'/': 'YOUR_HOME_PAGE_ID', // yourdomain.com/
'/about': 'YOUR_ABOUT_PAGE_ID', // yourdomain.com/about
'/blog': 'YOUR_BLOG_PAGE_ID', // yourdomain.com/blog
},
// SEO settings (optional but recommended)
seo: {
indexing: true, // Allow search engines to index
keywords: 'your, keywords, here',
defaultAuthor: 'Your Name',
},
// These are auto-generated — don't modify
customHeadCSS: HEAD_CSS_STRING,
customHeadJS: HEAD_JS_STRING,
customBodyJS: BODY_JS_STRING,
customHeader: HEADER_HTML_STRING,
};5. Generate
After editing your config, process the files:
npx nooxy generateRun this command every time you change anything in the nooxy/ folder.
6. Integrate with Your Runtime
Nooxy exports a single function that handles all requests. Add this to your server/worker entry point:
Need step-by-step deployment instructions? See the Full Deployment Guides for complete setup including file creation, testing, and deployment.
Cloudflare Workers:
import { initializeNooxy } from 'nooxy';
import { SITE_CONFIG } from '../nooxy/config';
const proxy = initializeNooxy(SITE_CONFIG);
export default {
async fetch(request: Request): Promise<Response> {
return proxy(request);
},
};Node.js (22+):
import { initializeNooxy } from 'nooxy';
import { SITE_CONFIG } from './nooxy/config';
import http from 'node:http';
const proxy = initializeNooxy(SITE_CONFIG);
// Most hosts inject the port they expect you to bind.
const PORT = Number(process.env.PORT ?? 8787);
http
.createServer(async (req, res) => {
const url = `http://${req.headers.host}${req.url}`;
// Notion loads page content over POST /api/v3/..., so the body has to be
// forwarded or the page renders an empty shell. Node requires duplex: 'half'
// whenever the body is a stream.
const hasBody = req.method !== 'GET' && req.method !== 'HEAD';
const request = new Request(url, {
method: req.method,
headers: req.headers,
body: hasBody ? req : undefined,
duplex: hasBody ? 'half' : undefined,
});
const response = await proxy(request);
res.statusCode = response.status;
// Set-Cookie must be written as separate headers. headers.forEach() yields it
// once with the values comma-joined, and repeated setHeader() calls overwrite
// each other, so signing in would lose every cookie but the last.
const cookies = response.headers.getSetCookie();
if (cookies.length > 0) {
res.setHeader('set-cookie', cookies);
}
response.headers.forEach((value, key) => {
if (key.toLowerCase() !== 'set-cookie') {
res.setHeader(key, value);
}
});
res.end(Buffer.from(await response.arrayBuffer()));
})
.listen(PORT, () => {
console.log(`Server running at http://localhost:${PORT}`);
});Any Fetch-compatible runtime:
import { initializeNooxy } from 'nooxy';
import { SITE_CONFIG } from './nooxy/config';
const proxy = initializeNooxy(SITE_CONFIG);
// proxy(request: Request) => Promise<Response>
// Pass any standard Request, get a standard ResponseFull Deployment Guides
The Quick Start above covers Nooxy setup. If you need a complete walkthrough — from creating an account to deploying your live site — use these platform-specific guides:
| Platform | Guide | Description | | ---------------------- | ----------------------------------------------- | ---------------------------------------------------------- | | Cloudflare Workers | Full Guide → | Recommended. Free tier, global edge network, ~15 min setup | | Node.js | Coming soon | For self-hosted servers |
These guides include all the Nooxy setup steps plus platform-specific deployment instructions.
Configuration Reference
All configuration is in nooxy/config.js. After any changes, run npx nooxy generate to apply them.
Required Fields
| Field | Description | Example |
| -------------- | ------------------------------------------------------------------------- | ---------------------- |
| domain | Your custom domain (without https://) | example.com |
| notionDomain | Your Notion workspace domain (prevents serving unintended Notion content) | myname.notion.site |
| siteName | Site name for browser tabs, SEO, and og:site_name | My Portfolio |
| slugToPage | URL path → Notion page ID mapping (32-char hex IDs) | { '/': 'abc123...' } |
Generated Fields
These fields are auto-populated by npx nooxy generate from files in the nooxy/ folder. Don't edit them directly — edit the source files instead:
| Field | Source File | Purpose |
| --------------- | ------------------- | ------------------------------------------------------------ |
| customHeadCSS | nooxy/head.css | CSS injected into <head> |
| customHeadJS | nooxy/head.js | JavaScript injected into <head> (runs before page loads) |
| customBodyJS | nooxy/body.js | JavaScript injected before </body> (runs after page loads) |
| customHeader | nooxy/header.html | HTML injected into page header (navigation, banners, etc.) |
SEO Configuration
seo: {
// Enable search engine indexing (default: true)
// When true: removes Notion's noindex tags, adds canonical URLs,
// injects robots meta, generates sitemap.xml and robots.txt
indexing: true,
// Canonical domain (if different from domain)
// Use when Nooxy runs on a subdomain but SEO should point to main domain
// Affects: canonical URLs, og:url, twitter:url, sitemap.xml
canonicalDomain: 'example.com',
// Path mapping for canonical URLs
// Maps paths from your Nooxy domain to the canonical domain
canonicalPathMap: {
'/': '/home', // subdomain.example.com/ → example.com/home
'/docs': '/documentation',
},
// Meta keywords (adds <meta name="keywords">)
keywords: 'notion, website, portfolio',
// Default author for all pages (adds <meta name="author"> and article:author)
// Can be overridden per-page in pageMetadata
defaultAuthor: 'Your Name',
// Replace "Notion" branding with your brand in all meta tags
// Affects: <title>, og:title, og:description, og:site_name, twitter:title, etc.
// Default: uses siteName
brandReplacement: 'Your Brand',
// AI crawler attribution (ChatGPT, Claude, Perplexity)
// Adds: <meta name="ai:source_url"> and <meta name="ai:source_attribution">
// Helps AI systems properly credit your content
aiAttribution: 'Your Name - yourdomain.com',
}Page-Specific Metadata
Override meta tags for specific pages. Key is the Notion page ID (32 characters):
pageMetadata: {
'abc123def456789012345678901234ab': {
title: 'Custom Page Title', // <title>, og:title, twitter:title
description: 'Custom meta description', // meta description, og:description, twitter:description
image: 'https://yourdomain.com/og.jpg', // og:image, twitter:image
author: 'Page Author Name', // article:author (overrides seo.defaultAuthor)
},
}Social & Branding
| Field | Description | Example |
| --------------- | ------------------------------------------------------------------- | --------------------------------- |
| twitterHandle | Twitter/X handle for twitter:site meta tag (include @) | @yourusername |
| siteIcon | Custom favicon URL (.ico format). If not set, uses Notion's default | https://example.com/favicon.ico |
The icon is fetched by your server, so a few limits apply. Any of these falls back to Notion's favicon and logs the reason — your site keeps working either way:
- must be
http://orhttps:// - must not be a loopback or link-local address (
localhost,127.x.x.x,::1,169.254.x.x) — a private LAN address such as10.0.0.5is fine, so serving the icon from another container works - must respond within 3 seconds and be under 512 KB
Typography & Analytics
| Field | Description | Example |
| ------------- | -------------------------------------------------------------------------------------------- | ----------------- |
| googleFont | Google Font family name from fonts.google.com. Applied site-wide | Inter, Roboto |
| googleTagID | Google Analytics 4 measurement ID. Injects GA4 tracking script | G-XXXXXXXXXX |
404 Page
Custom Notion page to display for 404 errors:
fof: {
page: 'NOTION_404_PAGE_ID', // Your custom 404 page (32-char ID)
slug: '/404', // URL path (default: '/404')
}Subdomain Redirects
Redirect subdomains to your main domain. Common use: redirect www to non-www:
subDomains: {
www: {
redirect: 'https://example.com', // www.example.com → example.com (301 redirect)
},
}Nooxy Configuration
nooxy: {
// Show "Made with Nooxy" badge in header (default: true)
// Set to false to hide it... 💔 it'll break my heart, but hey,
// if it helps your site look cleaner, I'll survive... probably 😢
showBadge: true,
}💜 If Nooxy helped you, a GitHub sponsorship would mean the world!
Auto-Generated Features
These features work automatically — no configuration needed:
| Feature | URL | Description |
| ------------------ | -------------- | -------------------------------------------------------------- |
| Sitemap | /sitemap.xml | Auto-generated XML sitemap with all pages from slugToPage |
| Robots.txt | /robots.txt | Points crawlers to your sitemap |
| Clean URLs | — | Rewrites Notion URLs (/Page-abc123) to your slugs (/about) |
| JSON-LD Schema | — | Injects structured data for rich search results |
| Canonical URLs | — | Adds <link rel="canonical"> to every page |
| Open Graph | — | Rewrites og:url, og:site_name for proper social sharing |
| Twitter Cards | — | Rewrites twitter:url, twitter:site for Twitter/X previews |
CLI Commands
# Create nooxy/ folder with config files
npx nooxy init
# Process config changes (run after editing any nooxy/ file)
npx nooxy generate
# Generate with custom path
npx nooxy generate --path=./my-project
# Generate without minification (for debugging)
npx nooxy generate --no-minify
# Print the installed version (useful in bug reports)
npx nooxy --versionHosting Several Sites From One Deployment
One worker can serve any number of Notion sites. Each one needs its own
configKey, which is what keeps its config separate:
import { initializeNooxy } from 'nooxy';
import { SITE_CONFIG as SITE_A } from '../nooxy-a/config';
import { SITE_CONFIG as SITE_B } from '../nooxy-b/config';
const siteA = initializeNooxy({ configKey: 'site-a', config: SITE_A });
const siteB = initializeNooxy({ configKey: 'site-b', config: SITE_B });
export default {
async fetch(request: Request): Promise<Response> {
const host = new URL(request.url).hostname;
return host.endsWith('b.example') ? siteB(request) : siteA(request);
},
};Config is processed once and cached per key. If you omit configKey, the site's
domain is used as the key, so two sites on different domains stay separate
anyway — but passing an explicit key is clearer, and it is required if two sites
share a domain. Reusing one key for two different configs logs a warning and keeps
the first, since silently serving one site's domain for another is worse.
Project Files
When you run npx nooxy init, it creates a nooxy/ folder with these files:
nooxy/
├── config.js # Main configuration (domain, pages, SEO settings)
├── head.css # Custom CSS injected into <head>
├── head.js # JavaScript injected into <head> (runs before page loads)
├── body.js # JavaScript injected before </body> (runs after page loads)
└── header.html # Custom HTML injected into page headerHow Files Are Processed
- You edit the source files (
head.css,body.js, etc.) - Run
npx nooxy generate— this reads the files, minifies them, and writes them tonooxy/generated/(whichconfig.jsimports) - When your site runs, Nooxy injects these into every page response
npx nooxy generate
+---------------+ +---------------+
| head.css | ----------------------> | |
| head.js | (minifies & embeds) | config.js |
| body.js | ----------------------> | |
| header.html | | |
+---------------+ +---------------+Important: Run
npx nooxy generateevery time you change anything in thenooxy/folder. Your changes won't take effect until you regenerate and redeploy.
File Reference
config.js — Main Configuration
Contains all your site settings. See Configuration Reference for all options.
export const SITE_CONFIG = {
domain: 'yourdomain.com',
notionDomain: 'yourworkspace.notion.site',
siteName: 'Your Site Name',
slugToPage: {
'/': 'YOUR_PAGE_ID',
},
// ... other options
};head.css — Custom Styles
CSS injected into <head>. Use this to override Notion's default styles:
/* Hide Notion's top bar */
.notion-topbar {
display: none !important;
}
/* Custom page styling */
.notion-page-content {
max-width: 900px;
margin: 0 auto;
}
/* Dark mode support */
.dark .notion-page-content {
background: #1a1a1a;
}Tips:
- Use
!importantto override Notion's styles - Notion uses
.darkclass for dark mode - Inspect your Notion page to find class names to target
head.js — Early JavaScript
JavaScript injected into <head>. Runs before the page content loads. Use for:
- Analytics that need to run early
- Setting up global variables
- Theme detection before render
// Example: Set theme before page renders to prevent flash
const theme = localStorage.getItem('theme') || 'light';
document.documentElement.setAttribute('data-theme', theme);body.js — Main JavaScript
JavaScript injected before </body>. Runs after the page content loads. Use for:
- DOM manipulation
- Event listeners
- Interactive features
document.addEventListener('DOMContentLoaded', () => {
// Add smooth scrolling
document.querySelectorAll('a[href^="#"]').forEach((anchor) => {
anchor.addEventListener('click', (e) => {
e.preventDefault();
document.querySelector(anchor.getAttribute('href')).scrollIntoView({
behavior: 'smooth',
});
});
});
});header.html — Custom Header
HTML injected at the top of the page body. Use for navigation bars, announcements, or banners:
<nav style="padding: 1rem; background: #f5f5f5; display: flex; gap: 1rem;">
<a href="/">Home</a>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>
</nav>After Making Changes
Every time you edit any file in nooxy/:
# 1. Regenerate the config
npx nooxy generate
# 2. Redeploy (platform-specific)
npm run deploy # Cloudflare Workers
# or restart your server # Node.jsContent changes in Notion appear automatically — no regeneration needed. Only changes to nooxy/ files require regeneration.
How It Works
User Request --> Nooxy --> Notion
| |
| <-----+ (fetches HTML)
|
v
[Rewrite & Inject]
|
v
User Response <-- Modified HTML- Intercepts requests to your custom domain
- Maps clean URLs (
/about) to Notion page IDs - Fetches content from Notion's servers
- Rewrites meta tags, URLs, and structured data for SEO
- Injects your custom CSS, JavaScript, and headers
- Serves the optimized response to visitors
Compared to Alternatives
vs Notion Sites
| | Notion Sites | Nooxy | | -------------------- | ------------ | --------- | | Price | $10-22/mo | Free | | Custom domain | Paid add-on | Included | | SEO control | Limited | Full | | CSS/JS injection | No | Yes | | noindex removal | Manual | Automatic |
vs Super.so
| | Super.so | Nooxy | | ------------------------ | ------------- | --------- | | Price | $12-28/mo | Free | | Notion interactivity | Lost (static) | Preserved | | Database filtering | No | Yes | | Real-time updates | No | Yes |
vs Fruition
| | Fruition | Nooxy | | ---------------- | --------- | ------------- | | Maintained | No (2020) | Yes (2024+) | | SEO features | Basic | Comprehensive | | TypeScript | No | Yes | | CLI tools | No | Yes |
Troubleshooting
| Problem | Solution |
| -------------------------------- | ------------------------------------------------------------------------------------------------------- |
| Pages return 404 | Verify page IDs are 32 characters, pages are published in Notion, notionDomain matches your workspace |
| CSS not applied | Run npx nooxy generate after changes, use !important to override Notion styles |
| SEO tags not appearing | Ensure seo.indexing is true, view page source (not rendered DOM), redeploy |
| "Cannot find module 'nooxy'" | Run npm install nooxy |
Support
- GitHub Issues: Report bugs or request features
- Discussions: Ask questions
- Email: [email protected]
Contributing
See CONTRIBUTING.md for guidelines.
License
MIT License - see LICENSE for details.
Trademark
Nooxy and the Nooxy badge are trademarks of David Raphi. The MIT license covers the source code, not the name.
Forks and derivative works are welcome. Please give yours a different name, and do not present it in a way that suggests it is Nooxy or endorsed by the project. See TRADEMARK.md for what is and is not allowed.
Acknowledgments
Inspired by Fruition and NoteHost, rebuilt for the modern web with comprehensive SEO, TypeScript, and zero dependencies.
Made with ❤️ by David Raphi
Stop paying for Notion hosting. Start with Nooxy.
npm install nooxy && npx nooxy initStar this repo if it saved you $120+/year.
