The only Notion reverse proxy with full SEO, zero dependencies, and complete customization.
Quick Start · Why Nooxy · Features · Configuration · Customization · Deployment Guides
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.
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!
|
|
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
- 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
- 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
- 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
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
In your project folder (where package.json is), run:
npm install nooxynpx 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)
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).
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,
};After editing your config, process the files:
npx nooxy generateRun this command every time you change anything in the nooxy/ folder.
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 ResponseThe 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.
All configuration is in nooxy/config.js. After any changes, run npx nooxy generate to apply them.
| 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...' } |
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: {
// 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',
}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)
},
}| 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
| 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 |
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')
}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: {
// 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!
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 |
# 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 --versionOne 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.
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 header
- 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.
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
};CSS injected into <head>. Use this to override Notion's default styles:
/* 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 own styles - Notion uses
.darkclass for dark mode - Inspect your Notion page to find class names to target
Overriding Nooxy's own styles. Nooxy injects its stylesheet after yours, so a
rule with the same specificity loses even though yours appears first. Here
!important is not enough on its own — you also have to be more specific.
Nooxy hides Notion's top bar with div.notion-topbar, div.notion-topbar-mobile,
so bringing it back means answering both, and html body is what wins the
cascade:
html body div.notion-topbar {
display: block !important;
}
/* Notion swaps in a different bar on small screens, and that one sets
display: flex inline — forcing block here would break its layout. */
html body div.notion-topbar-mobile {
display: flex !important;
}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);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',
});
});
});
});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>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.
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
| 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 |
| Super.so | Nooxy | |
|---|---|---|
| Price | $12-28/mo | Free |
| Notion interactivity | Lost (static) | Preserved |
| Database filtering | No | Yes |
| Real-time updates | No | Yes |
| Fruition | Nooxy | |
|---|---|---|
| Maintained | No (2020) | Yes (2024+) |
| SEO features | Basic | Comprehensive |
| TypeScript | No | Yes |
| CLI tools | No | Yes |
| 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 |
- GitHub Issues: Report bugs or request features
- Discussions: Ask questions
- Email: contact@draphy.org
See CONTRIBUTING.md for guidelines.
MIT License - see LICENSE for details.
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.
Inspired by Fruition and NoteHost, rebuilt for the modern web with comprehensive SEO, TypeScript, and zero dependencies.
Made with ❤️ by David Raphi
npm install nooxy && npx nooxy initStar this repo if it saved you $120+/year.
