Modern, fast, and beautiful blog software powered by Nuxt UI v4 and Cloudflare Workers
NuxtPress is a modern blogging platform that's:
- Fast: Server-rendered on Cloudflare's edge network
- Simple: One Worker, one D1 database, no other infrastructure
- Secure: User accounts with administrator and author roles, sealed session cookies
- Findable: Canonical URLs, an Atom feed, and a sitemap that lists every post
- Free: Runs on Cloudflare's free tier
- Click the "Deploy to Cloudflare" button above
- Connect your GitHub account and let Cloudflare fork the repository
- Cloudflare provisions the Worker, the D1 database and the KV namespaces from
wrangler.jsonc - Set
NUXT_SESSION_PASSWORDandNUXT_ANALYTICS_SALT(see below), then redeploy - Open your blog and create the first administrator at
/setup
git clone https://github.com/gmitch215/nuxtpress.git
cd nuxtpress
bun install
bun run build
bunx wrangler --cwd .output deployThe build targets Cloudflare Workers, so the whole app deploys as one Worker. Do not deploy
.output/public on its own; that directory holds only the static assets and none of the server.
Connect the repository in Workers & Pages
and Cloudflare rebuilds and redeploys on every push to master.
After deployment, you'll need to configure your blog through environment variables:
| Variable | Description | Required |
|---|---|---|
NUXT_SESSION_PASSWORD |
32+ char secret used to seal session cookies | yes (prod) |
NUXT_ANALYTICS_SALT |
Random secret used to derive daily visitor hashes | yes for analytics |
NUXT_PASSWORD |
Bootstrap password for a seeded admin account |
no since 1.3.0 |
Generate NUXT_SESSION_PASSWORD with: openssl rand -base64 48.
When you deploy for the first time, NuxtPress detects that no users exist in the database and redirects every visitor to /setup, an onboarding screen where you create the first administrator account (username, display name, password, optional bio). After that, log in normally with those credentials.
NUXT_PASSWORD is not required. If you do set it:
- On a brand-new install, the migration seeds a user named
admin(display name Team) with that password, so you can log in immediately without visiting/setup. - The admin account's password is locked while
NUXT_PASSWORDis set. You cannot change it from the profile or admin user UI. Remove the env var and redeploy to manage the password from the app.
As of v1.4.0 the password only works because it is stored as a normal password hash on that seeded account. The env variable is no longer accepted as a login credential on its own, and /api/login requires both a username and a password. Treat NUXT_PASSWORD as a one-time bootstrap helper; the long-term flow is /setup plus the user-management UI.
These can be configured via environment variables or through the admin panel after logging in:
| Variable | Description | Default |
|---|---|---|
NUXT_PUBLIC_SITE_URL |
Your blog's public URL | https://nuxtpress.pages.dev |
NUXT_PUBLIC_NAME |
Your blog name | NuxtPress |
NUXT_PUBLIC_DESCRIPTION |
Blog description | My NuxtPress blog |
NUXT_PUBLIC_AUTHOR |
Author name | Gregory Mitchell |
NUXT_PUBLIC_THEME_COLOR |
Theme color (hex) | #1e40af |
NUXT_PUBLIC_FAVICON |
Favicon URL | /_favicon.ico |
NUXT_PUBLIC_FAVICON_PNG |
PNG favicon URL | /_favicon.png |
NUXT_PUBLIC_GITHUB |
GitHub profile URL | (empty) |
NUXT_PUBLIC_INSTAGRAM |
Instagram profile URL | (empty) |
NUXT_PUBLIC_TWITTER |
Twitter/X profile URL | (empty) |
NUXT_PUBLIC_PATREON |
Patreon profile URL | (empty) |
NUXT_PUBLIC_LINKEDIN |
LinkedIn profile URL | (empty) |
NUXT_PUBLIC_DISCORD |
Discord server URL | (empty) |
NUXT_PUBLIC_SUPPORT_EMAIL |
Support email address | (empty) |
How to set environment variables:
Plain values go in the Cloudflare dashboard under Workers & Pages β your Worker β Settings β Variables. Secrets are better set from the CLI, which keeps them out of the dashboard's plaintext list:
bunx wrangler secret put NUXT_SESSION_PASSWORD
bunx wrangler secret put NUXT_ANALYTICS_SALTIf you used this repository as a template and want to pull updates from the original template repository, you can add the template as a remote and merge updates into your repository. The following snippet shows the minimal commands you may use.
# one time
git remote add template https://github.com/gmitch215/nuxtpress
# to install an update
git switch master
git fetch --all
git merge template --allow-unrelated-histories -m "chore: merge upstream"Notes:
- The first
git remote addonly needs to be run once per cloned repository. - Switch to the branch you want to update (here
master) before merging. --allow-unrelated-historiesis included to allow merging between repositories that do not share history; resolve conflicts if they appear.- Always create a local backup branch before merging so you can recover easily if something goes wrong:
git switch -c before-template-mergeExamples for targeting a specific branch, tag, or commit
- Merge a specific branch from the template (e.g.
experimental):
git fetch template
git switch master
git merge template/experimental --allow-unrelated-histories -m "chore: merge template/experimental"- Merge a specific tag from the template (e.g.
v1.0.2):
git fetch --tags template
git switch master
git merge v1.0.2 --allow-unrelated-histories -m "chore: merge template v1.0.2"If you prefer to create a local branch that mirrors the tagged state, you can do:
git fetch --tags template
git checkout -b update-v1.0.2 v1.0.2
# Inspect changes, then merge into master if desired
git switch master
git merge update-v1.0.2- Apply a single commit from the template (e.g.
ab12ef3):
git fetch template
git switch master
git cherry-pick ab12ef3Alternatives and safety tips:
- Instead of merging directly into
master, consider creating a temporary update branch (git switch -c update-from-template) and test there before merging to your main branch. - If the template remote is updated frequently, run
git fetch templateto refresh refs before merging. - If you encounter conflicts, resolve them and then
git committo complete the merge.
- Open your blog and click "Log In"
- Enter the username and password of the account you created at
/setup
Administrators can add more accounts at /admin/users, as either administrators or authors. An
author can write and edit their own posts; an administrator can also manage users and settings.
- Log in, then click "New Post" on the home page
- Fill in:
- Title: Your post title
- Slug: URL segment, generated from the title and editable. It must be unique across the blog
- Content: Markdown, with a rich-text editor and a live preview
- Tags: Comma-separated tags
- Thumbnail (optional): Upload an image or point at an external URL
- Click "Publish"
Drafts save as you type and reappear in the form the next time you open it.
- Edit: Open a post while logged in and click the pencil next to the title
- Delete: Open a post and click the bin next to the title
- View: Every post is listed on the home page, with archives by year, month and day
- Log in, then click the settings icon on the home page
- Update the blog name, description, author, theme color, favicons, social links, post URL style, and the optional site-wide banner message
- Settings save to KV and take effect immediately
Every post answers on two URLs:
/2026/9/8/my-postβ the dated permalink/my-postβ the slug on its own
Both always return the post. The Post URL Style setting picks which one the blog links to and which one search engines treat as canonical; the other keeps working instead of returning a 404, so existing links never break. The default is the dated form.
Slugs have to be unique across the whole blog, because a slug addresses a post on its own. Creating or renaming a post to a slug that is already taken returns a 409.
The dashboard behind the chart icon reports views, unique visitors, active reading time, scroll completion, top posts, top pages, referrers, countries, operating systems, devices, browsers, and the signed-in share of traffic.
Visitors are counted with a salted SHA-256 hash of IP address and user agent that rotates every day, so the same person is one visitor within a day and unlinkable across days. What is never stored: IP addresses, user agents, account identities, and anything more precise than a two-letter country code. Requests carrying DNT: 1 or Sec-GPC: 1 are dropped before anything is written, as are known crawlers. Set NUXT_ANALYTICS_SALT to a random value so the hashes cannot be reproduced from a leaked database.
NuxtPress is a full-stack blogging platform built with:
- Frontend: Nuxt 4 with Vue 3, Nuxt UI v4, and Tailwind CSS
- Backend: Nitro server routes running on Cloudflare Workers
- Database: Cloudflare D1 through Drizzle ORM
- Storage: Cloudflare KV for settings, caches and analytics; blob storage for avatars
- Auth:
nuxt-auth-utilssealed session cookies, validated against the users table per request - Validation: Zod schemas shared between client and server
- Deployment: One Cloudflare Worker, deployed with Wrangler or Workers Builds
- Nuxt 4: Vue framework, server-rendered
- Nuxt UI v4: Component library
- @nuxthub/core: D1, KV and blob bindings, with local equivalents for
nuxt dev - Drizzle ORM: Typed SQL against D1
- nuxt-auth-utils: Sealed session cookies and password hashing
- Cloudflare D1: SQLite on the edge
- Cloudflare KV: Key-value storage
- Zod: Schema validation
- marked + highlight.js: Markdown rendering and syntax highlighting
- TipTap: Rich-text editing in the post form
- unovis: Analytics charts
- Tailwind CSS v4: Utility-first CSS
- Playwright: End-to-end tests
- Bun, or Node.js 22.19+ (Nuxt 4 requires 22.19, 24.11, or 26 and up)
- A Cloudflare account, for deployment only. Local development needs no Cloudflare resources
-
Clone the repository:
git clone https://github.com/gmitch215/nuxtpress.git cd nuxtpress -
Install dependencies:
bun install
-
Create a
.envwith a session secret. Everything else has a working default:printf 'NUXT_SESSION_PASSWORD=%s\nNUXT_ANALYTICS_SALT=%s\n' \ "$(openssl rand -base64 48)" "$(openssl rand -hex 16)" > .env
See Configuration for the full list.
Edit
.envand set yourNUXT_PASSWORDand other configuration. -
Run the development server:
bun run dev
bun run dev- Start the development server on port 8787bun run dev:test- Start the dev server with the test environment filebun run dev:cloudflare- Build, then serve the Worker locally through Wranglerbun run dev:setup-test- Start a second dev server on port 8788 against an empty data directory, used by the first-run setup specbun run build- Build for productionbun run test- Run the Playwright suitebun run test:headed- Run the suite with a visible browserbun run test:coverage- Run the suite with V8 coverage reportingbun run test:report- Open the last HTML test reportbun run prettier- Format code with Prettierbun run prettier:check- Check code formatting
NuxtHub's own hosting platform was sunset on 31 December 2025. NuxtPress still uses
@nuxthub/core for its D1, KV and blob bindings, but deployment and hosting are now entirely
your own Cloudflare account. If you deployed an earlier NuxtPress through the NuxtHub dashboard,
the changes are:
- Database: Drizzle ORM, replacing
hubDatabase() - Bindings: declared in
wrangler.jsoncrather than generated for you - Deployment: Wrangler or Workers Builds
Create a wrangler.jsonc file in your project root to link your existing D1 database and KV namespaces:
Find your resource IDs in the Cloudflare Dashboard:
- D1 Database ID: Workers & Pages β D1 β Your Database
- KV Namespace IDs: Workers & Pages β KV β Your Namespaces
The database migration happens automatically on the first request after deployment:
- Creates the
blog_poststable if it doesn't exist - Detects old timestamp format (seconds vs milliseconds)
- Converts timestamps automatically if needed
- Creates indexes
If you notice that your timestamps are incorrect after migration, you can manually run the provided SQL script migrations/migrate_timestamps.sql against your D1 database using the Cloudflare Dashboard's D1 query interface.
# test locally first - then use '--remote' for Cloudflare
bunx wrangler d1 execute DB --file "migrations/migrate_timestamps.sql"Use Cloudflare's deployment tools:
Via Workers Builds:
- Connect your Git repository in Workers & Pages
- Automatic deployments on every push
Or via Wrangler CLI:
bun run build
bunx wrangler --cwd .output deploySet environment variables in the Cloudflare dashboard under Workers & Pages β your Worker β
Settings β Variables, and set secrets with bunx wrangler secret put <NAME>.
nuxtpress/
βββ src/
β βββ app.vue # Root component: site-wide meta, canonical, schema.org
β βββ error.vue # Error page
β βββ assets/css/ # Global styles and prose styles
β βββ components/
β β βββ analytics/TopTable.vue # Top posts / top pages table
β β βββ blog/Article.vue # Post view, shared by both permalinks
β β βββ AnalyticsDashboard.vue # Admin analytics modal
β β βββ BlogForm.vue # Post create/edit form
β β βββ BlogPostGroup.vue # Post listing
β β βββ SearchButton.vue # Command-palette post search
β β βββ SettingsForm.vue # Site settings form
β βββ composables/
β β βββ useAnalytics.ts # Client beacon: active time, scroll depth
β β βββ useBlogPostPage.ts # Loads and describes one post
β β βββ useBlogPostSeo.ts # Article meta and structured data
β β βββ useCanonical.ts # Site origin and rel=canonical
β β βββ useDatabase.ts # Post list and settings state
β β βββ usePostUrl.ts # Permalink resolution
β β βββ useSanitizeHtml.ts # Markdown output sanitizer
β βββ middleware/ # Route guards: auth, admin, first-run setup
β βββ pages/
β β βββ index.vue # Home
β β βββ [slug].vue # Slug-only permalink
β β βββ [year]/β¦/[slug].vue # Dated permalink and date archives
β β βββ admin/users.vue # User management
β β βββ authors/[username].vue # Author profile
β β βββ setup.vue # First-run onboarding
β βββ plugins/ # Client plugins, including page-view tracking
β βββ server/
β β βββ api/ # Blog, auth, users, settings, analytics, sitemap
β β βββ db/schema.ts # Drizzle schema
β β βββ middleware/ # Session bootstrap
β β βββ plugins/ # Session hooks
β β βββ routes/ # feed.xml, favicons, avatars, thumbnails
β β βββ utils/ # auth, db, posts, analytics, ua
β βββ shared/ # Zod schemas and types shared with the client
β βββ types/ # Session type augmentation
βββ tests/ # Playwright specs and helpers
βββ scripts/ # Maintenance scripts
βββ migrations/ # Manual SQL for older installs
βββ public/ # Static files
βββ nuxt.config.ts
βββ playwright.config.ts
βββ wrangler.jsonc # D1 and KV bindings
βββ tsconfig.json
All API routes are located in src/server/api/ and are automatically available at /api/*.
Authenticate with a username and password. Both fields are required as of v1.4.0.
Request Body:
{
"username": "admin",
"password": "your-password"
}Response:
{
"ok": true
}Sets a sealed, HttpOnly, SameSite=Lax session cookie, marked Secure in production. No
Max-Age is configured, so the cookie lasts until the browser session ends. The session payload
is re-validated against the users table on every request, so deactivating an account or changing
a role takes effect immediately rather than at the next login.
Invalidate the current session.
Response:
{
"ok": true
}Check if the current session is valid.
Response:
{
"loggedIn": true
}Get current blog settings (public endpoint).
Response:
{
"name": "My Blog",
"description": "Blog description",
"author": "Author Name",
"themeColor": "#1e40af",
"favicon": "/favicon.ico",
"faviconPng": "/favicon.png",
"github": "https://github.com/username",
"twitter": "https://twitter.com/username",
"instagram": "https://instagram.com/username",
"patreon": "https://patreon.com/username",
"linkedin": "https://linkedin.com/in/username",
"discord": "https://discord.gg/server-invite",
"supportEmail": "support@example.com"
}Update blog settings (requires authentication).
Request Body: Same as GET response (partial updates supported)
Response: Updated settings object
Get every blog post as a summary (public endpoint). Summaries carry a pre-rendered excerpt
instead of the full content, and thumbnails come back as a URL rather than inline base64, so
the response stays small as the blog grows. Use /api/blog/find for a single post's body.
Response:
[
{
"id": "unique-id",
"title": "Post Title",
"slug": "post-slug",
"excerpt": "First 200 characters of the post as plain text...",
"thumbnail_url": "/thumbnails/unique-id",
"created_at": "2025-11-17T00:00:00.000Z",
"updated_at": "2025-11-17T00:00:00.000Z",
"tags": ["tag1", "tag2"],
"author_id": "author-id",
"author": { "username": "admin", "displayName": "Team", "role": "administrator" }
}
]Search posts by title, body, tag or slug (public endpoint).
Query Parameters:
q(required): search text, 1-200 characters. Anything else returns[].
Response: Array of post summaries, same shape as list, capped at 25 results.
Find a single blog post, including its full content.
Query Parameters:
slug(required): Post slugyear,month,day(optional): pass all three to match a post published on that date. Omit them to get the newest post with that slug.
Response: Single blog post object with content
Serve a post's stored thumbnail as an image, cached for a day. Posts with a thumbnail_url of
their own do not use this route.
Create a new blog post (requires authentication).
Request Body:
{
"post": {
"title": "Post Title",
"slug": "post-slug",
"content": "Post content...",
"thumbnail": "base64-encoded-image-data",
"tags": ["tag1", "tag2"]
}
}Response: Created blog post object
Note: Slugs must be unique across the blog, since a slug addresses a post on its own. A duplicate returns a 409 rather than being silently renamed.
Update an existing blog post (requires authentication).
Request Body:
{
"id": "post-id",
"post": {
"title": "Updated Title",
"content": "Updated content...",
"tags": ["updated-tags"]
}
}Response: Updated blog post object
Delete a blog post (requires authentication).
Query Parameters:
id: Post ID to delete
Response:
{
"ok": true
}| Endpoint | Method | Access | Purpose |
|---|---|---|---|
/api/setup/status |
GET | public | Whether any user exists yet |
/api/setup/init |
POST | public until set up | Create the first administrator; 409 afterwards |
/api/users/{username} |
GET | public | Author profile and their posts |
/api/users/me |
PATCH | authenticated | Update own display name, bio or password |
/api/users/me/avatar |
POST / DELETE | authenticated | Upload or clear own avatar |
/api/admin/users |
GET / POST | administrator | List or create users |
/api/admin/users/{id} |
PATCH / DELETE | administrator | Update or delete a user |
Guards worth knowing: you cannot delete your own account, and you cannot demote or delete the last remaining administrator.
| Endpoint | Method | Access | Purpose |
|---|---|---|---|
/api/blog/draft |
GET | authenticated | List your saved drafts |
/api/blog/draft |
POST | authenticated | Save a draft, keyed by slug |
| Endpoint | Method | Access | Purpose |
|---|---|---|---|
/api/analytics/track |
POST | public | Beacon endpoint. Always 204, never reports why a hit was dropped |
/api/analytics/summary |
GET | administrator | Aggregated report; range is 7d, 30d, 90d or all |
| Route | Purpose |
|---|---|
/feed.xml |
Atom feed of the 50 most recent posts |
/sitemap.xml |
Every post, date archive and author page |
/robots.txt |
Crawl rules. Indexing is disabled in development |
/thumbnails/{id} |
A post's stored thumbnail image |
/avatars/{pathname} |
A user's avatar image |
/favicon.ico, /favicon.png |
Site icons, proxied when set to an external URL |
NuxtPress uses Cloudflare D1. Both tables are created and migrated on the first request after a deploy, so there is no manual migration step.
CREATE TABLE IF NOT EXISTS blog_posts (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
slug TEXT NOT NULL,
content TEXT NOT NULL,
thumbnail TEXT,
thumbnail_url TEXT,
author_id TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
tags TEXT
);
CREATE INDEX idx_blog_posts_slug ON blog_posts(slug);
CREATE INDEX idx_blog_posts_created_at ON blog_posts(created_at);
CREATE INDEX idx_blog_posts_slug_created_at ON blog_posts(slug, created_at);
CREATE INDEX idx_blog_posts_author ON blog_posts(author_id);id: 32-character hex identifierslug: URL segment. Unique across the table, enforced by the create and update endpointscontent: Markdownthumbnail: base64 image, served by/thumbnails/{id}thumbnail_url: external image URL, used in preference tothumbnailauthor_id: referencesusers.id, set to null when that user is deletedcreated_at/updated_at: milliseconds since the epochtags: comma-separated list, null when the post has no tags
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
role TEXT NOT NULL,
bio TEXT,
avatar_pathname TEXT,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000),
updated_at INTEGER NOT NULL DEFAULT (unixepoch() * 1000)
);
CREATE UNIQUE INDEX idx_users_username ON users(username);username: 3-32 characters, lowercase letters, numbers, hyphens and underscorespassword_hash: scrypt hash fromnuxt-auth-utils. Plaintext passwords are never storedrole:administratororauthoravatar_pathname: key into blob storage, served by/avatars/{pathname}is_active:0suspends the account and invalidates its sessions on the next request
Contributions are welcome! Here's how to get started:
-
Fork the repository
git clone https://github.com/YOUR-USERNAME/nuxtpress.git
-
Create a feature branch
git checkout -b feature/amazing-feature
-
Make your changes
- Follow the existing code style
- Run
bun run prettierbefore committing - Ensure types are correct with TypeScript
-
Commit your changes
git commit -m "feat: add amazing feature"Follow Conventional Commits format.
-
Push and create a Pull Request
git push origin feature/amazing-feature
- Code Style: Prettier is configured and runs automatically on commit via Husky
- TypeScript: Strict mode is enabled; ensure all types are correct
- Components: Use Nuxt UI components when possible for consistency
- API Routes: Use Zod schemas for validation
- Tests: Playwright covers every page and API route. Run
bun run testbefore opening a pull request - Server Utils: Helper functions live in
src/server/utils/: auth, database, post queries, analytics - Database: Always use parameterized queries to prevent SQL injection
This project is open source and available under the MIT License.
{ "$schema": "node_modules/wrangler/config-schema.json", "d1_databases": [ { "binding": "DB", "database_name": "your-database-name", "database_id": "your-database-id" } ], "kv_namespaces": [ { "binding": "KV", "id": "your-kv-namespace-id" }, { "binding": "CACHE", "id": "your-cache-namespace-id" } ] }