A stickler for good typography, I was glad to see this tiny pull request merged into Ghost.
Details, details 🕵️♂️
Ghost#32057
3 likes
It looks like Ghost is moving in a direction to support this via Better Auth but that might not be anytime soon... Ghost#30368
1 like
@marclou TryGhost/Ghost I will go with Ghost
6 views
Session-Based Authentication in Node.js and TypeScript with HttpOnly Cookies and CSRF Protection
Most authentication tutorials end at "the user is logged in". That is the easy part. The hard part is everything around it: where the password actually goes, where the session lives, what happens when someone steals a cookie, and why your "protected" endpoint still accepts requests from a random website.
A common way this goes wrong: the session token sits in localStorage, an XSS bug or a compromised dependency injects a script, and every token in every open tab can be sent to an attacker's server with one fetch. HttpOnly cookies exist to stop exactly that.
This tutorial builds an authentication API that takes the boring parts seriously: Argon2id password hashing, server-side sessions, an HttpOnly session cookie, CSRF protection that matches the architecture, and a small test suite that covers the main failure paths.
What you will build
A small JSON API with four endpoints:
| Method | Path | Purpose |
| ------ | ------------------- | ---------------------------------------- |
| POST | /api/auth/register| Create a user and start a session |
| POST | /api/auth/login | Verify credentials and rotate the session|
| GET | /api/auth/me | Return the signed-in user |
| POST | /api/auth/logout | Destroy the session |
Plus GET /api/csrf-token for CSRF tokens and GET /api/health.
The stack:
- Node.js 22 (LTS) with TypeScript. Tested on Node v22.23.2. Node 22 is in Maintenance LTS, with end-of-life on 2027-04-30.
- Express 5
- Argon2id for password hashing
- SQLite (via better-sqlite3) for users and sessions
- express-session with a custom SQLite store
- csrf-sync for the synchronizer token pattern
- Zod for input validation
- Vitest and supertest for tests
The versions below were verified against the npm registry and the local runtime while writing: express@5.3.0, express-session@1.19.0, argon2@0.45.1, better-sqlite3@13.0.3, zod@4.6.7, helmet@8.3.0, csrf-sync@4.2.1, supertest@7.3.1, vitest@5.0.4, typescript@7.0.2, on node@v22.23.2.
The architecture, and why
Browser Server
| |
|-- POST /api/auth/login -------->| verify Argon2id hash
|<-- Set-Cookie: sid=... ---------| create session row in SQLite
| (HttpOnly; SameSite=Lax) | session data stays server-side
| |
|-- GET /api/auth/me -------------| look up session by signed id
| Cookie: sid=... | load userId from the store
|<-- 200 {user} ----------------- |
Three decisions matter here.
The session lives on the server. The cookie carries only a signed, random session ID. The user's identity is stored in a database row, not in the browser. Logging out actually revokes access, because the server can delete the row. A stateless JWT can't be revoked without extra machinery.
The cookie is HttpOnly. document.cookie can't read it, so an XSS bug can't exfiltrate the session. MDN is explicit that HttpOnly doesn't stop the browser from sending the cookie, which is exactly why the next point matters.
Cookies are sent automatically, so the API needs CSRF protection. If the browser attaches your session cookie to a request from evil.example, then "the request carries a valid session" is not proof the user intended it. A CSRF token that a cross-site attacker can't read fixes that.
The threat model, briefly
| Threat | Mitigation in this tutorial |
| --- | --- |
| Database leak exposes passwords | Argon2id + unique salt per password |
| XSS steals the session | HttpOnly cookie; session data server-side |
| Session hijacking in transit | Secure cookie in production, HTTPS |
| Cross-site request forgery | Synchronizer token in a custom header |
| Session fixation | Regenerate the session ID on login |
| User enumeration via login timing | Verify against a dummy hash when the email is unknown |
| User enumeration via registration | Not solved here - a duplicate email still returns 409; see the limitations section |
| Brute force | Not solved here - see the limitations section |
1. Project setup
mkdir secure-auth-demo && cd secure-auth-demo
npm init -y
npm pkg set type=module
npm install express express-session argon2 better-sqlite3 zod helmet csrf-sync
npm install -D typescript tsx vitest supertest @types/express @types/express-session \
@types/better-sqlite3 @types/supertest @types/node@^22
Two dependency notes worth recording:
- csurf is deprecated. Express's own blog announced the retirement of legacy packages including csurf and points developers at a strategy that fits their architecture. For session-based apps, csrf-sync implements the OWASP synchronizer token pattern directly. Don't copy older tutorials that still install csurf.
- sqlite3 is no longer maintained. Its upstream repository, TryGhost/node-sqlite3, was archived on 1 July 2026 and is read-only, so no further releases are coming. (The final npm release, 6.0.1, is not itself flagged deprecated; the archival is the reason to avoid it.) better-sqlite3 is the actively maintained alternative, and its synchronous API also removes a layer of callback plumbing.
package.json scripts:
{
"type": "module",
"engines": { "node": ">=22" },
"scripts": {
"dev": "tsx watch --env-file=.env src/server.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node --env-file-if-exists=.env dist/server.js",
"typecheck": "tsc --noEmit",
"test": "vitest run"
}
}
--env-file is built into Node, so you don't need dotenv. The start script uses --env-file-if-exists: without a .env file, the config validator reports the missing SESSION_SECRET (tested on Node v22.23.2 only). With the plain --env-file flag, I ran npm start without a .env file and it failed with node: .env: not found. One caveat I hit while testing: Node's --env-file doesn't override variables that are already set in the environment. The file is a default source, not an override. If your shell exports PORT, that wins.
TypeScript configuration
TypeScript's Node target mapping lists Node 22 with target/lib ES2023 and module: "nodenext". With ESM and NodeNext, relative imports must include the emitted .js extension.
tsconfig.json (used for type-checking everything, including tests):
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "tests", "vitest.config.ts"]
}
tsconfig.build.json narrows the build to src so tests are not shipped:
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "src",
"outDir": "dist",
"sourceMap": true
},
"include": ["src"]
}
2. Environment configuration
src/config.ts validates the environment once at boot:
import { z } from "zod";
/**
* The whole app reads configuration from here, and the process refuses to
* start with an invalid configuration instead of failing later at runtime.
*/
/**
* A blank env var (for example `PORT=` in a .env file) should fall back to the
* default rather than being coerced into 0 or NaN.
*/
function optionalNumber(fallback: number) {
return z.preprocess(
(value) => (typeof value === "string" && value.trim() === "" ? undefined : value),
z.coerce.number().int().positive().default(fallback),
);
}
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
PORT: optionalNumber(3000),
// The secret used to sign the session cookie. 32+ bytes of randomness.
SESSION_SECRET: z
.string()
.min(32, "SESSION_SECRET must be at least 32 characters of randomness"),
DATABASE_PATH: z.string().min(1).default("./data/app.db"),
// How long a session stays valid, in milliseconds. Default: 8 hours.
SESSION_TTL_MS: optionalNumber(1000 * 60 * 60 * 8),
});
const parsed = EnvSchema.safeParse(process.env);
if (!parsed.success) {
const details = parsed.error.issues
.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`)
.join("\n");
throw new Error(`Invalid environment configuration:\n${details}`);
}
export const config = parsed.data;
export const isProduction = config.NODE_ENV === "production";
export const isTest = config.NODE_ENV === "test";
.env.example:
NODE_ENV=development
PORT=3000
# Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
SESSION_SECRET=
DATABASE_PATH=./data/app.db
SESSION_TTL_MS=28800000
Generate a real secret instead of typing one:
node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"
The min(32) check in the config is only a length check. It can't tell a random string from a guessable one. express-session signs the session ID with HMAC-256, and its README says the secret should contain at least 32 bytes of entropy, which is what the generated value gives you. An earlier version of .env.example had a placeholder here. I copied it as-is and the app booted with it, because the 45-character placeholder passed the 32-character check. That's why the value is blank now: the app refuses to start until you generate a real secret. Add .env, data/, and dist/ to .gitignore.
3. Database and users
src/db.ts opens SQLite and creates the schema:
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import Database from "better-sqlite3";
import { config } from "./config.js";
// ":memory:" is used by the test suite; a file path is used everywhere else.
if (config.DATABASE_PATH !== ":memory:") {
mkdirSync(dirname(config.DATABASE_PATH), { recursive: true });
}
export const db = new Database(config.DATABASE_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
export function migrate(): void {
db.exec(`
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sessions (
sid TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at
ON sessions (expires_at);
`);
}
migrate();
src/users.ts is the data access layer. Emails are normalized before they reach it, so a plain UNIQUE column is enough:
import { db } from "./db.js";
export interface User {
id: number;
email: string;
passwordHash: string;
createdAt: number;
}
interface UserRow {
id: number;
email: string;
password_hash: string;
created_at: number;
}
function toUser(row: UserRow): User {
return {
id: row.id,
email: row.email,
passwordHash: row.password_hash,
createdAt: row.created_at,
};
}
export function findUserByEmail(email: string): User | undefined {
const row = db
.prepare("SELECT id, email, password_hash, created_at FROM users WHERE email = ?")
.get(email) as UserRow | undefined;
return row ? toUser(row) : undefined;
}
export function findUserById(id: number): User | undefined {
const row = db
.prepare("SELECT id, email, password_hash, created_at FROM users WHERE id = ?")
.get(id) as UserRow | undefined;
return row ? toUser(row) : undefined;
}
export function insertUser(email: string, passwordHash: string): User {
const row = db
.prepare(
`INSERT INTO users (email, password_hash, created_at)
VALUES (?, ?, ?)
RETURNING id, email, password_hash, created_at`,
)
.get(email, passwordHash, Date.now()) as UserRow;
return toUser(row);
}
Every query uses bound parameters (?), so a crafted email can't inject SQL.
4. Password hashing
OWASP's Password Storage Cheat Sheet recommends Argon2id first, and lists one of its minimum configurations: 19 MiB of memory, 2 iterations, parallelism 1 (m=19456, t=2, p=1).
import argon2 from "argon2";
/**
* One of the minimum Argon2id configurations listed in the OWASP Password Storage Cheat Sheet: m=19456 (19 MiB), t=2, p=1.
*/
const ARGON2_OPTIONS = {
type: argon2.argon2id,
memoryCost: 19456,
timeCost: 2,
parallelism: 1,
} as const;
export function hashPassword(password: string): Promise<string> {
return argon2.hash(password, ARGON2_OPTIONS);
}
export async function verifyPassword(hash: string, password: string): Promise<boolean> {
try {
// The encoded hash carries its own algorithm and parameters, so no options
// are needed here.
return await argon2.verify(hash, password);
} catch {
return false;
}
}
argon2.verify takes no algorithm option, because the encoded hash string already records the algorithm, memory cost, iterations, and salt, which is why a single password_hash column is enough. verifyPassword returns false on a malformed hash rather than throwing, so a corrupted row can't produce a 500.
Why not bcrypt? OWASP lists bcrypt as an acceptable choice for legacy systems, with a work factor of at least 10, and calls out its 72-byte input limit: passwords longer than that are silently truncated in some implementations. Argon2id is the better default for new code.
5. A SQLite-backed session store
express-session's default MemoryStore is explicitly not for production: it leaks memory and can't be shared across processes. Since SQLite is already in the project, a small store is the cheapest correct option.
express-session calls touch only if the store has one. This setup depends on it, because rolling: true uses it to push the expiry forward. A maintained package, better-sqlite3-session-store, is listed in the express-session README. I wrote a small one here so you can see what a store has to do.
import session from "express-session";
import type { SessionData } from "express-session";
import { config } from "./config.js";
import { db } from "./db.js";
interface SessionRow {
data: string;
expires_at: number;
}
export class SqliteSessionStore extends session.Store {
override get(sid: string, callback: (err: unknown, session?: SessionData | null) => void): void {
try {
const row = db
.prepare("SELECT data, expires_at FROM sessions WHERE sid = ?")
.get(sid) as SessionRow | undefined;
if (!row) {
callback(null, null);
return;
}
if (row.expires_at <= Date.now()) {
// Expired sessions are deleted lazily, on the next read.
this.destroy(sid, () => callback(null, null));
return;
}
callback(null, JSON.parse(row.data) as SessionData);
} catch (error) {
callback(error);
}
}
override set(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void {
try {
const expiresAt = this.expiresAtFor(sessionData);
db.prepare(
`INSERT INTO sessions (sid, data, expires_at) VALUES (?, ?, ?)
ON CONFLICT (sid) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at`,
).run(sid, JSON.stringify(sessionData), expiresAt);
callback?.();
} catch (error) {
callback?.(error);
}
}
override destroy(sid: string, callback?: (err?: unknown) => void): void {
try {
db.prepare("DELETE FROM sessions WHERE sid = ?").run(sid);
callback?.();
} catch (error) {
callback?.(error);
}
}
override touch(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void {
try {
db.prepare("UPDATE sessions SET expires_at = ? WHERE sid = ?").run(
this.expiresAtFor(sessionData),
sid,
);
callback?.();
} catch (error) {
callback?.(error);
}
}
/** Removes every expired row. Useful as a periodic cleanup job. */
pruneExpired(): number {
return db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now()).changes;
}
private expiresAtFor(sessionData: SessionData): number {
const { expires, maxAge } = sessionData.cookie ?? {};
if (expires) {
return new Date(expires).getTime();
}
if (typeof maxAge === "number") {
return Date.now() + maxAge;
}
return Date.now() + config.SESSION_TTL_MS;
}
}
Expiry is enforced in two places: the store refuses to return an expired row, and pruneExpired() sweeps the table. Expired sessions are deleted on read, so a stale cookie is treated as signed out.
6. Session middleware and secure cookies
import type { Request } from "express";
import session from "express-session";
import { config, isProduction } from "./config.js";
import { SqliteSessionStore } from "./session-store.js";
/**
* OWASP recommends the `__Host-` prefix for session cookies: it forces the
* browser to require Secure + Path=/ and to reject any Domain attribute, which
* makes cookie tossing harder. Browsers refuse `__Host-` cookies over plain
* HTTP, so we only use it in production where TLS is mandatory.
*/
export const SESSION_COOKIE_NAME = isProduction ? "__Host-sid" : "sid";
export const sessionStore = new SqliteSessionStore();
type RequestHandler = ReturnType<typeof session>;
export function createSessionMiddleware(): RequestHandler {
return session({
name: SESSION_COOKIE_NAME,
secret: config.SESSION_SECRET,
store: sessionStore,
// Do not re-save untouched sessions. Fetching a CSRF token modifies the
// session, so anonymous visitors who call /api/csrf-token do get a session row.
resave: false,
saveUninitialized: false,
// Slide the expiry forward on activity, so active users are not logged out
// mid-session while idle sessions still expire.
rolling: true,
cookie: {
httpOnly: true, // not readable from JavaScript
secure: isProduction, // HTTPS only in production
sameSite: "lax", // blocks cross-site POSTs; see the CSRF section
path: "/",
maxAge: config.SESSION_TTL_MS,
},
});
}
export function regenerateSession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.regenerate((err) => (err ? reject(err) : resolve()));
});
}
export function saveSession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.save((err) => (err ? reject(err) : resolve()));
});
}
export function destroySession(req: Request): Promise<void> {
return new Promise((resolve, reject) => {
req.session.destroy((err) => (err ? reject(err) : resolve()));
});
}
Every cookie attribute here matters:
- httpOnly: true. The browser won't expose this cookie to JavaScript. MDN defines it exactly that way. This is the single most important attribute for session cookies.
- secure: isProduction. The cookie is only sent over HTTPS (browsers make an exception for localhost).
- sameSite: "lax". The cookie isn't attached to cross-site fetch calls or cross-site POSTs, which removes the simplest CSRF vectors. MDN documents Lax as allowing same-site requests plus cross-site top-level navigations using safe methods. Strict is stronger but hurts usability: clicking a link into your app from another site would arrive logged out.
- No domain attribute. Omitting it makes this a host-only cookie. OWASP recommends this, and it is required for the __Host- prefix.
- maxAge. A bounded lifetime instead of a session cookie that lives until the browser closes.
The three helper functions wrap the callback-based express-session API in promises so the route handlers stay readable. They are also why the tests can assert on await.
Session fixation
An attacker who can force a victim to use a session ID the attacker already knows can take over the session. Regenerating the ID on every privilege change (registration, login, and role change) makes that useless. regenerateSession(req) does that, and it is called on both login and registration.
One consequence you must handle in the client: the CSRF token lives in the session, so it is discarded by regeneration. After logging in, fetch /api/csrf-token again. This surprises people; it's not a bug.
7. CSRF protection
OWASP recommends the synchronizer token pattern for stateful apps: the server generates a secret token tied to the session and validates a submitted copy on unsafe methods. csrf-sync implements exactly that, storing the token in req.session.csrfToken.
import { csrfSync } from "csrf-sync";
/**
* csrf-sync implements the OWASP "synchronizer token pattern" for stateful
* (session-based) apps.
*
* Defaults we rely on, and why:
* - The token is stored server-side in `req.session.csrfToken`.
* - Unsafe methods (everything except GET/HEAD/OPTIONS) must present the token
* in the `x-csrf-token` request header.
* - Invalid or missing tokens produce an error with code `EBADCSRFTOKEN`,
* which the error handler turns into a 403.
*/
export const { csrfSynchronisedProtection, generateToken } = csrfSync();
I checked the installed source while writing this: csrf-sync ignores GET, HEAD, and OPTIONS by default, reads the token from the x-csrf-token header, generates 128 random bytes (a 256-character hex token), and raises an error with code: "EBADCSRFTOKEN".
The token must not live in a cookie, and it must not be sent in a URL, because either would defeat the point. A custom header works because reading a custom header requires CORS permission the attacker doesn't have.
8. Routes
import { Router } from "express";
import { z } from "zod";
import { isProduction } from "../config.js";
import { hashPassword, verifyPassword } from "../password.js";
import { requireAuth, currentUserId } from "../middleware/require-auth.js";
import {
SESSION_COOKIE_NAME,
destroySession,
regenerateSession,
saveSession,
} from "../session.js";
import { findUserByEmail, findUserById, insertUser } from "../users.js";
export const authRouter = Router();
/** Normalise the email before validating it, so case never creates duplicates. */
const emailSchema = z.string().trim().toLowerCase().pipe(z.email());
const registerSchema = z.object({
email: emailSchema,
// Length matters more than character-class rules. NIST SP 800-63B recommends
// allowing long passphrases; we require at least 12 characters and cap the
// length so a huge body cannot be used to burn CPU.
password: z.string().min(12).max(200),
});
// Login must not re-apply the registration policy: doing so would leak the
// policy and lock out accounts created before a policy change.
const loginSchema = z.object({
email: emailSchema,
password: z.string().min(1).max(200),
});
interface PublicUser {
id: number;
email: string;
}
function toPublicUser(user: { id: number; email: string }): PublicUser {
return { id: user.id, email: user.email };
}
function isUniqueViolation(error: unknown): boolean {
return (
typeof error === "object" &&
error !== null &&
"code" in error &&
(error as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE"
);
}
/**
* A throwaway hash used when the submitted email does not exist. A failed login
* then performs one real Argon2id verification whether or not the account
* exists, which narrows the timing gap. It does not remove it: the database
* lookup and session handling still differ, so treat this as best-effort.
*/
let dummyHash: Promise<string> | undefined;
function getDummyHash(): Promise<string> {
dummyHash ??= hashPassword("timing-equalisation-placeholder-value");
return dummyHash;
}
void getDummyHash(); // warm up at startup so the first unknown-email login isn't slower
authRouter.post("/register", async (req, res) => {
const parsed = registerSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
error: "Invalid registration details",
issues: parsed.error.issues.map((issue) => ({
field: issue.path.join("."),
message: issue.message,
})),
});
return;
}
const { email, password } = parsed.data;
if (findUserByEmail(email)) {
res.status(409).json({ error: "Email is already registered" });
return;
}
const passwordHash = await hashPassword(password);
let user;
try {
user = insertUser(email, passwordHash);
} catch (error) {
// Two requests can pass the check above at the same time; the UNIQUE
// constraint is the real guarantee, so translate it into the same 409.
if (isUniqueViolation(error)) {
res.status(409).json({ error: "Email is already registered" });
return;
}
throw error;
}
// Never keep the pre-login session id after a privilege change.
await regenerateSession(req);
req.session.userId = user.id;
await saveSession(req);
res.status(201).json({ user: toPublicUser(user) });
});
authRouter.post("/login", async (req, res) => {
const parsed = loginSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: "Invalid login payload" });
return;
}
const { email, password } = parsed.data;
const user = findUserByEmail(email);
const hash = user?.passwordHash ?? (await getDummyHash());
const passwordMatches = await verifyPassword(hash, password);
if (!user || !passwordMatches) {
// Identical response for "no such user" and "wrong password" so the API
// does not confirm which emails exist.
res.status(401).json({ error: "Invalid email or password" });
return;
}
await regenerateSession(req);
req.session.userId = user.id;
await saveSession(req);
res.json({ user: toPublicUser(user) });
});
authRouter.get("/me", requireAuth, (req, res) => {
const userId = currentUserId(req);
const user = userId === undefined ? undefined : findUserById(userId);
if (!user) {
res.status(401).json({ error: "Authentication required" });
return;
}
res.json({ user: toPublicUser(user) });
});
authRouter.post("/logout", async (req, res) => {
await destroySession(req);
res.clearCookie(SESSION_COOKIE_NAME, {
httpOnly: true,
secure: isProduction,
sameSite: "lax",
path: "/",
});
res.status(204).end();
});
A few notes on that code:
- toPublicUser never includes passwordHash. Don't serialize database rows straight to JSON. An explicit projection is the difference between a working API and a hash-leak.
- Normalize, then validate. z.string().trim().toLowerCase().pipe(z.email()) means " Alice@Example.COM " and "alice@example.com" are the same account.
- The dummy-hash trick. When the email is unknown, the code still runs a real Argon2id verification against a throwaway hash, so a failed login does the same expensive work whether or not the account exists. Without it, a request naming a non-existent email would skip hashing entirely and return noticeably faster, a timing side channel that can disclose which emails are registered. This narrows the gap; it doesn't make the two paths identical, because the database lookup and session handling still differ. I saw the lazy version of this: my first unknown-email login after server start took 0.0457 s against about 0.024 s afterwards, because the dummy hash was created lazily.
- One error message for both failures. "Invalid email or password" for both cases avoids a second enumeration channel.
- The database constraint is the real guard. The pre-check is for a friendly 409; the UNIQUE constraint is what actually prevents two accounts in a race. Both paths return the same response.
SP 800-63B-4 sets 15 characters as the minimum for single-factor passwords; this demo uses 12 to balance security with user friction.
src/middleware/require-auth.ts:
import type { NextFunction, Request, Response } from "express";
/** The id of the authenticated user, or `undefined` if not signed in. */
export function currentUserId(req: Request): number | undefined {
const { userId } = req.session;
return typeof userId === "number" ? userId : undefined;
}
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
if (currentUserId(req) === undefined) {
res.status(401).json({ error: "Authentication required" });
return;
}
next();
}
And the session type augmentation, src/types/express-session.d.ts:
import "express-session";
declare module "express-session" {
interface SessionData {
/** The authenticated user's id. Absent for anonymous visitors. */
userId?: number;
}
}
9. App wiring: order is the specification
import express, { type Express } from "express";
import helmet from "helmet";
import { isProduction } from "./config.js";
import { csrfSynchronisedProtection, generateToken } from "./middleware/csrf.js";
import { errorHandler, notFoundHandler } from "./middleware/error-handler.js";
import { authRouter } from "./routes/auth.js";
import { createSessionMiddleware } from "./session.js";
export function createApp(): Express {
const app = express();
// Behind a TLS-terminating proxy, trust the first hop so express-session sees
// X-Forwarded-Proto and will set `Secure` cookies correctly.
if (isProduction) {
app.set("trust proxy", 1);
}
app.disable("x-powered-by");
app.use(helmet());
app.use(express.json({ limit: "16kb" }));
app.use(createSessionMiddleware());
app.get("/api/health", (_req, res) => {
res.json({ status: "ok" });
});
// Hand the client a CSRF token bound to its session. GET is a safe method, so
// this route is exempt from CSRF checks.
app.get("/api/csrf-token", (req, res) => {
res.json({ csrfToken: generateToken(req) });
});
app.use(csrfSynchronisedProtection);
app.use("/api/auth", authRouter);
app.use(notFoundHandler);
app.use(errorHandler);
return app;
}
Order matters:
1. helmet() sets security headers on every response, including errors, so it goes first.
2. express.json({ limit: "16kb" }) must precede anything reading req.body, and the size limit stops a giant body from burning CPU in Argon2. Requests over the limit are rejected with 413 before any handler runs.
3. The session middleware must precede CSRF and routes, because both need req.session.
4. /api/csrf-token must be registered before csrfSynchronisedProtection. Register it after, and clients can never obtain a token for their first mutation. It's an easy mistake to make.
5. CSRF protection guards every unsafe request below it.
6. notFoundHandler and errorHandler come last.
A note if you are new to Express 5: it automatically forwards rejected promises from async handlers to the error middleware, so the route handlers above don't need try/catch around every await. That was a manual next(err) in Express 4.
src/middleware/error-handler.ts:
import type { NextFunction, Request, Response } from "express";
interface HttpErrorLike {
code?: string;
type?: string;
status?: number;
statusCode?: number;
}
export function notFoundHandler(_req: Request, res: Response): void {
res.status(404).json({ error: "Not found" });
}
/**
* Central error handler. It must keep all four parameters — Express detects
* error-handling middleware by arity.
*/
export function errorHandler(
err: unknown,
_req: Request,
res: Response,
next: NextFunction,
): void {
if (res.headersSent) {
next(err);
return;
}
const httpError = err as HttpErrorLike;
if (httpError?.code === "EBADCSRFTOKEN") {
res.status(403).json({ error: "Invalid or missing CSRF token" });
return;
}
// body-parser marks malformed JSON with type: "entity.parse.failed".
if (httpError?.type === "entity.parse.failed") {
res.status(400).json({ error: "Malformed JSON body" });
return;
}
// Other body-parser failures carry their own 4xx status (for example
// "entity.too.large" -> 413 from the 16kb limit, and "encoding.unsupported"
// -> 415). Honour it instead of reporting a server fault for a client error.
const status = httpError?.status ?? httpError?.statusCode;
if (typeof status === "number" && status >= 400 && status < 500) {
res.status(status).json({
error: httpError?.type === "entity.too.large" ? "Request body too large" : "Bad request",
});
return;
}
// Anything else is unexpected: log it server-side and return a generic
// message so stack traces and SQL errors never reach the client.
console.error("Unhandled error:", err);
res.status(500).json({ error: "Internal server error" });
}
The handler maps each failure to a status: EBADCSRFTOKEN to 403, a malformed JSON body to 400, and other body-parser errors to the 4xx status they already carry, for example entity.too.large to 413 when a request exceeds the 16kb limit. Without that last branch an oversized body falls through to the generic case and is reported as a 500 server fault, which misleads the client and buries a client error in your server logs.
The error handler must keep all four parameters, because Express identifies error middleware by arity. Dropping the unused next silently turns it into ordinary middleware that never runs.
10. Entry point
import { createApp } from "./app.js";
import { config } from "./config.js";
import { sessionStore } from "./session.js";
const app = createApp();
const server = app.listen(config.PORT, () => {
console.log(`Auth API listening on http://localhost:${config.PORT} (${config.NODE_ENV})`);
});
const PRUNE_INTERVAL_MS = 30 * 60 * 1000;
const pruneTimer = setInterval(() => {
const removed = sessionStore.pruneExpired();
if (removed > 0) {
console.log(`Pruned ${removed} expired session(s)`);
}
}, PRUNE_INTERVAL_MS);
pruneTimer.unref();
function shutdown(signal: NodeJS.Signals): void {
console.log(`${signal} received, shutting down`);
clearInterval(pruneTimer);
server.close(() => {
process.exit(0);
});
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
11. Tests
vitest.config.ts injects a deterministic environment, including an in-memory database so each run starts clean:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
env: {
NODE_ENV: "test",
PORT: "3000",
// 32+ characters so it passes config validation.
SESSION_SECRET: "test-only-secret-value-please-change-me-0001",
// In-memory SQLite: each test file gets its own throwaway database.
DATABASE_PATH: ":memory:",
},
},
});
The integration tests drive the app through supertest with a cookie-persisting agent. The suite has 6 cases, and this is the whole file:
import request from "supertest";
import { describe, expect, it } from "vitest";
import { createApp } from "../src/app.js";
import { db } from "../src/db.js";
const app = createApp();
const VALID_PASSWORD = "correct horse battery staple";
type Agent = ReturnType<typeof request.agent>;
function setCookieHeaders(res: request.Response): string[] {
const raw = res.headers["set-cookie"];
if (!raw) return [];
return Array.isArray(raw) ? raw : [raw];
}
function sessionCookie(res: request.Response): string {
const cookie = setCookieHeaders(res).find((value) => value.startsWith("sid="));
if (!cookie) throw new Error("Response did not set a session cookie");
return cookie;
}
async function getCsrfToken(agent: Agent): Promise<string> {
const res = await agent.get("/api/csrf-token");
expect(res.status).toBe(200);
return res.body.csrfToken as string;
}
async function register(agent: Agent, email: string, password = VALID_PASSWORD) {
const token = await getCsrfToken(agent);
return agent.post("/api/auth/register").set("x-csrf-token", token).send({ email, password });
}
describe("auth api", () => {
it("sets an HttpOnly, SameSite=Lax session cookie", async () => {
const agent = request.agent(app);
const res = await register(agent, "cookie@example.com");
const cookie = sessionCookie(res);
expect(cookie).toMatch(/HttpOnly/i);
expect(cookie).toMatch(/SameSite=Lax/i);
expect(cookie).not.toMatch(/;\s*Secure/i); // dev/test, so it also works over HTTP
});
it("rejects a duplicate email with 409", async () => {
const first = request.agent(app);
expect((await register(first, "dup@example.com")).status).toBe(201);
const second = request.agent(app);
const res = await register(second, "dup@example.com");
expect(res.status).toBe(409);
});
it("rotates the session id on login to prevent session fixation", async () => {
const agent = request.agent(app);
const registered = await register(agent, "rotate@example.com");
const before = sessionCookie(registered);
const token = await getCsrfToken(agent);
const loggedIn = await agent
.post("/api/auth/login")
.set("x-csrf-token", token)
.send({ email: "rotate@example.com", password: VALID_PASSWORD });
expect(loggedIn.status).toBe(200);
expect(sessionCookie(loggedIn)).not.toBe(before);
});
it("rejects an unknown email with the same message as a wrong password", async () => {
const agent = request.agent(app);
const token = await getCsrfToken(agent);
const res = await agent
.post("/api/auth/login")
.set("x-csrf-token", token)
.send({ email: "nobody@example.com", password: VALID_PASSWORD });
expect(res.status).toBe(401);
expect(res.body.error).toBe("Invalid email or password");
});
it("blocks a state-changing request that has no token", async () => {
const res = await request(app)
.post("/api/auth/register")
.send({ email: "no-csrf@example.com", password: VALID_PASSWORD });
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/CSRF/i);
});
it("invalidates the session on logout", async () => {
const agent = request.agent(app);
await register(agent, "logout@example.com");
expect((await agent.get("/api/auth/me")).status).toBe(200);
const token = await getCsrfToken(agent);
expect((await agent.post("/api/auth/logout").set("x-csrf-token", token)).status).toBe(204);
expect((await agent.get("/api/auth/me")).status).toBe(401);
});
});
My first version of the fourth test failed with TypeError: Cannot read properties of null (reading 'address') from supertest, because I awaited the CSRF token inside the request chain. Fetching the token into a variable first, as the test above does, fixes it.
Run it:
npm test
Expected result on this codebase:
Test Files 1 passed (1)
Tests 6 passed (6)
Duration 808ms (import 45%, tests 43%, transform 11%, worker 1%)
Durations differ between machines; the counts are what matter. Also run npm run typecheck (which must exit 0) and npm run build before deploying anywhere; build compiles src to dist/ via tsconfig.build.json.
12. Trying it by hand
With npm run dev running, this curl sequence exercises the whole flow. The cookie jar is what makes the session persist across calls.
JAR=$(mktemp); B=http://localhost:3000
# 1. Grab a CSRF token; the cookie jar now holds the session cookie.
TOKEN=$(curl -s -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/')
# 2. Register (201)
curl -s -b "$JAR" -c "$JAR" -X POST $B/api/auth/register \
-H 'Content-Type: application/json' -H "x-csrf-token: $TOKEN" \
-d '{"email":"me@example.com","password":"correct horse battery staple"}'
# 3. Get a fresh token (the session was rotated) and read the profile (200)
TOKEN=$(curl -s -b "$JAR" -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/')
curl -s -b "$JAR" $B/api/auth/me
# 4. Logout (204), then confirm access is gone (401)
curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" -c "$JAR" -X POST $B/api/auth/logout -H "x-csrf-token: $TOKEN"
curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" $B/api/auth/me
Output from this project, including the Set-Cookie header (session ID shortened):
Set-Cookie: sid=s%3A<id>.<signature>; Path=/; Expires=Wed, 14 Oct 2026 19:53:24 GMT; HttpOnly; SameSite=Lax
Note there is no Secure flag because NODE_ENV=development. Set NODE_ENV=production, terminate TLS, and the cookie becomes __Host-sid=...; Secure; HttpOnly; SameSite=Lax.
Common mistakes and troubleshooting
"403 on every POST, even with a token." Three usual causes. The token endpoint is registered after csrfSynchronisedProtection. Or the client is reading csrfToken from the wrong place. Or the client is sending the token as a cookie or a query parameter instead of the x-csrf-token header.
"Login succeeds but the very next mutation fails with 403." That's expected. Login regenerates the session, which discards the CSRF token. Fetch a new token after authenticating.
"The cookie is set but never sent back." Check, in order: secure: true over plain HTTP (the browser drops it, with a localhost exception); sameSite: "strict" breaking a top-level navigation from another origin; a domain mismatch; or a cross-origin client that is not sending credentials. For fetch calls, credentials: "include" is required, along with explicit CORS Access-Control-Allow-Credentials: true and a specific origin, because * is not allowed with credentials.
"req.session is undefined in a route." The session middleware is registered after the route, or after CSRF. Both need it, so it must come first.
"Users get logged out randomly." resave and saveUninitialized are usually to blame, or the store is not saving. With saveUninitialized: false, a session is only created when it is actually modified, which is correct for login flows.
"Argon2 install fails." argon2 and better-sqlite3 are native modules. Most platforms get prebuilt binaries; if not, the resolution is a working build toolchain, not a switch to a pure-JS hash.
"JSON.parse throws inside the session store." If you changed the stored shape, clear the sessions table. A schema change to session data effectively logs everyone out.
"SESSION_SECRET must be at least 32 characters" on boot. That message comes from the config validator. Generate a value rather than inventing one. It also fires if SESSION_SECRET is left blank in your .env.
What this tutorial does not cover
This is not a finished product. Deliberately out of scope:
- Registration discloses whether an email is registered. POST /api/auth/register answers 409 for an email that already exists, so anyone can probe the endpoint for accounts. Login is protected against this; registration is not, because the answer the caller wants is also the answer that makes the API useful. Hiding it requires moving account creation behind an email-verification step.
- Rate limiting, lockout, and hashing cost. Nothing here slows down credential stuffing. Add per-IP and per-account throttling (for example express-rate-limit) and alert on repeated failures. Argon2id is expensive by design, so a burst of login attempts can exhaust worker capacity. The 16kb body limit and the 200-character password cap bound one request's input; they don't bound the request rate, which is the rate limiter's job.
- Email verification, password reset, and MFA. Email verification and password reset both need a token with its own single-use, expiring lifecycle, and MFA should be added before anything else.
- Anonymous session rows. Every GET /api/csrf-token without a cookie creates a row, and nothing limits how many. Three cookie-less calls added three rows to my sessions table. My first draft's comment claimed anonymous visitors don't get a session.
- No absolute session lifetime. rolling: true slides the expiry forward on every authenticated request, so an active session never expires on its own.
- No breached-password check. SP 800-63B-4 says passwords SHALL be compared against a blocklist; this code doesn't.
- Secret rotation, audit logging, and observability. OWASP's session guidance covers renewing session IDs and detecting session-related attacks; this code does neither. There are no metrics, no tracing, and no structured logging.
- Scaling. SQLite is a single-writer database. It is a fine fit for one process or one machine, and it is the wrong fit for horizontal scaling. The store interface is the seam: swap in Redis or Postgres.
- __Host- prefix in development. It requires HTTPS, which is why it is production-only here. If you add local TLS, use it everywhere.
One more thing: disabling CSRF protection because "it is only an API" is not a mitigation. If your API is called from a browser with a cookie, it is a CSRF target.
Conclusion
The difference between "we added login" and "we added authentication" is a short list of decisions: hash with Argon2id at OWASP's parameters, keep session state on the server, mark the cookie HttpOnly and Secure, add SameSite, protect unsafe methods with a synchronizer token, regenerate the session ID on login, and make login fail the same way for an unknown email and a wrong password so it doesn't leak which accounts exist (registration still does, see the limitations).
The complete, runnable project, including the SQLite session store and all 6 tests, is at secure-auth-demo. npm install && npm test should print 6 passed.
References
- OWASP Password Storage Cheat Sheet - Argon2id parameters, bcrypt caveats, scrypt and PBKDF2 guidance
- OWASP Session Management Cheat Sheet - session ID entropy, cookie attributes, __Host- prefix, session renewal and destruction
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet - synchronizer token pattern, token transmission, SameSite as defense in depth
- MDN: Set-Cookie header - exact semantics of HttpOnly, Secure, and SameSite
- MDN: Using HTTP cookies - cookie scope, lifetime, and security
- Node.js CLI documentation (--env-file) - env file loading and precedence
- Node.js releases - which Node versions are LTS
- TypeScript Node target mapping - target, lib, and module for Node 22
- TypeScript module option - NodeNext semantics
- Express 5 migration guide - promise rejection handling for async handlers
- Express: retiring legacy packages - why csurf is deprecated
- express-session on npm - MemoryStore is not production-ready
- csrf-sync on npm - synchronizer token pattern for sessions
- argon2 on npm - hashing and verification API
- better-sqlite3 on npm - synchronous SQLite for Node
- NIST SP 800-63B-4 - password length and memorized-secret guidance
1 reaction
Why is Wix better than WordPress?
Wix is better than WordPress at one job: giving a non-technical owner a working, hosted site that nobody has to patch. It is worse at the jobs WordPress was built for, which are owning your content, moving it somewhere else, and bolting on almost any feature through plugins or code. So the honest answer to the title is "better for some people, in some situations," and the situation is usually decided by two questions: who will maintain the site, and will you ever need to leave? If the answer is "nobody technical" and "probably not," Wix is a sensible pick. If it's "me or a developer" and "maybe," WordPress usually fits better.
Disclosure: I work on VeloCMS, which appears near the end as one of five alternatives. It gets about the same space as the others, its weak points are listed, and there's a section on when to skip it.
Last verified: 2026-10-11.
How I checked: every price, limit and feature below was read from the vendor's own page on 2026-10-11, with the link next to the claim, and prices are in USD as a US visitor sees them. Where a page wouldn't give me a number, I say so. Vendor marketing claims (uptime, "AI-powered," and so on) are labelled as claims, and my opinions are labelled as mine.
Is Wix actually better than WordPress?
Not in general. W3Techs, as of 2026-10-11, shows WordPress on 40.1% of all websites it tracks, which is 58.6% of sites with a known content management system. On that same known-CMS basis, Shopify has 7.8%, Wix 6.1%, Squarespace 3.6% and Webflow 1.2% (Wix is on 4.2% of all websites) (W3Techs). Popularity doesn't make WordPress better for you, but it does mean more tutorials, more freelancers and more ready-made answers.
"Better" only means something once you attach it to a task. People asking this are usually weighing upkeep, cost, flexibility, design freedom, ownership and speed to launch. Wix wins upkeep and launch speed for most beginners. WordPress wins ownership and flexibility. Cost and design swing either way.
Where does Wix genuinely beat WordPress?
Four places, and they're real.
Nobody has to patch anything
Wix says it applies security patches and updates automatically and runs DDoS protection and 24/7 security monitoring on every hosted site (Wix blog on hosting). That's the vendor describing itself, so read it as a claim, but the structural point holds anyway: a Wix owner has no plugin list to keep current.
That matters because the WordPress risk is concentrated in exactly that list. Patchstack counted 11,334 new vulnerabilities in the WordPress ecosystem in 2025. Of those, 91% were in plugins, 9% in themes and only 6 in WordPress core, all low priority. It also found 46% were not fixed by the time they were publicly disclosed, and that for heavily exploited flaws the median time to exploitation was about 5 hours (Patchstack, State of WordPress Security in 2026). The core software is fine. The mess is what you install around it.
Fairness check: WordPress does auto-update itself. Per the official upgrade docs, new installs since WordPress 5.6 get automatic minor and major core updates by default, but plugins and themes update automatically only in special cases chosen by the WordPress security team (WordPress developer docs). The rest is your job or your host's.
Hosting, SSL and support come in the box
A Wix premium plan bundles hosting, a certificate and help. Wix's own plan write-up says every premium tier includes multi-cloud hosting, built-in security infrastructure and 24/7 customer care (Wix premium plans), and its hosting article claims free HTTPS with an automatic SSL certificate, 99.99% uptime and a CDN with 200+ nodes (again, Wix's numbers, not an audit). When something breaks there is one company to call.
On WordPress you assemble that. The software is free, you choose a host, and WordPress.org recommends three: Pressable, Bluehost and Hostinger (WordPress.org hosting). Hostinger's WordPress page showed me Premium at $2.99 a month on a 48-month commitment, renewing at $10.99 (Hostinger).
The editor and the AI helper are built together
Wix's newer editor, Harmony, launched in January 2026 and mixes drag-and-drop with an AI agent called Aria, according to Wix's help center (About Wix Harmony). For someone who has never built a site, "describe it and then nudge it by hand" is a faster start than choosing a WordPress theme, installing a page builder and learning its panels. I'd call that an actual advantage.
It comes with caveats, all from that same help page: rollout is gradual, it's English only for now, CMS features are on a list of things Wix is "working on bringing," and custom code (JavaScript or backend) isn't available in Harmony. So the easy new editor and the developer-friendly Velo tooling currently live in different places.
One account, one bill
Payments, scheduling, a blog and a store all sit behind one subscription ladder. On the Wix plans page, the Core tier is where accepting payments, basic eCommerce, and scheduling and services first appear, with Business and Business Elite adding standard and advanced eCommerce (Wix plans). You don't pick a booking plugin and then check whether it plays nicely with your checkout plugin. If a business needs those things working together by Friday, that coherence is worth money.
Where does WordPress beat Wix?
Can you take your site with you?
This is the biggest gap, and it's the question I'd ask first. Wix's help center says a Wix site has to run on Wix's servers, because the platform's architecture doesn't support external hosting. You can attach a domain you bought elsewhere, but only on a paid plan (Wix help: exporting or embedding your site). There's no "download the site and upload it to a new host" option.
Content is patchy too. Wix's help center says that, today, it isn't possible to export blog posts to other platforms, and lists the capability as a feature request (Wix help: exporting blog posts). CMS collections are the exception: Wix's help center says you can export a collection to CSV, all fields or just the filtered rows. Useful, but it's data, not a site.
WordPress is the opposite. Its Tools, Export screen produces one XML (WXR) file containing posts, pages, custom post types, comments, custom fields, categories, tags, custom taxonomies and users, and another WordPress site can import it (WordPress export docs). The docs list content types and don't mention media files or theme design, so treat uploads and the look of the site as separate jobs. It isn't a magic wand. But your words come with you. The software is licensed under the GPL (version 2 or later), which is the legal basis for that freedom (WordPress.org about).
How big is the extension ecosystem?
WordPress.org describes its plugin directory as the largest directory of free and open source WordPress plugins and doesn't print a total on that page (WordPress plugin directory). The individual numbers are easier to see. Yoast SEO alone lists 10+ million active installations (Yoast SEO on WordPress.org), and WordPress.com says its paid plans can install 60,000+ plugins (WordPress.com pricing).
Wix has an App Market, but the page I read gave no total, only sub-collection counts such as 22 business-solution apps and 35 "Made by Wix" apps (Wix App Market), so I can't quote a catalog size. Nothing on Wix compares to dropping arbitrary PHP into a site. When the app you need doesn't exist, you're in Velo territory.
What happens when you need code?
Wix has a real answer, called Velo. In the regular editor you flip on Dev Mode and get JavaScript on the front end plus serverless Node.js on the back end, a built-in database, npm packages and scheduled jobs, all hosted on Wix's cloud (About Velo by Wix). For plenty of "I need one custom form handler" jobs, that's enough.
The ceilings show up in the smaller print. Custom code snippets require a published site with a connected domain, and Wix's help center says tracking scripts such as Google Analytics, Google Tag Manager and Facebook or TikTok pixels do not work through custom code (you use Wix's marketing integrations instead). It also notes that, as of December 2025, some snippets that used to work may now be blocked by new frontend security measures, and that snippets are deleted if you assign a different domain to the site (Wix help: embedding custom code).
Compare WordPress.com's Business plan, which lists SFTP/SSH, Git deployment and backups at $25 a month on annual billing (WordPress.com pricing), or a self-hosted site where you can run what your server allows. It isn't that WordPress is easy. It's that fewer walls exist.
Is Wix's SEO weaker?
Less than its reputation suggests. Wix's SEO page lists an editable robots.txt, automatic structured data on product, event and blog pages with options to customize it, editable canonical tags, custom meta titles and descriptions, editable slugs with automatic 301 redirects, and bulk redirect import of up to 500 URLs from a CSV (Wix SEO features). That covers most of what a small site needs.
WordPress pulls ahead when you want more than the basics, because SEO tooling is a plugin category with real competition, plus server-level control on self-hosted setups. That's my opinion about headroom, not a measured ranking, and I know of no fair head-to-head dataset that settles it. If you expect unusual schema or redirect rules, test that specific thing before committing.
Does the price stay low as you grow?
Wix prices climb in steps, and some of the steps are tied to what you can do. On the US version of the plans page, yearly billing paid in full at purchase and before tax, Light is $17 a month, Core $29, Business $39 and Business Elite $159 (Wix plans). Wix's own premium plans article quotes the same $17 to $159 range. Storage runs 2 GB on Light, 50 GB on Core and 100 GB on Business, and Business Elite is unlimited. Prices and currency vary by location and Wix sometimes promotes discounts, so read the number on the checkout page.
The free plan is limited by design: 500 MB of storage, 1 GB of bandwidth, a Wix address, Wix ads, no custom domain and no changing the template after creation (Wix help: free site).
Database-style content has a second meter. Wix lists collection item limits of 1,500 on Light, 4,000 on Core, 20,000 on Business and 10,000,000 on Business Elite and Elite (Wix help: collection limits).
Then there's payment processing. Wix Payments in the US lists 2.9% plus $0.30 per standard card transaction, 3.7% plus $0.30 for American Express, 3.49% plus $0.49 for PayPal and 6% plus $0.30 for buy-now-pay-later options, plus a 1.5% cross-border fee and 1% currency conversion on top where they apply. That support page doesn't make fees depend on the plan (Wix Payments fees). I didn't find a separate Wix commission on the plan page, so the fees you pay are the processor's.
Squarespace prints platform fees by plan (more below), and WordPress.com's Commerce plan lists 0% transaction fees (WordPress.com pricing). WordPress isn't free at scale either: the software costs $0, but the host renews higher than its intro price and premium themes or plugins are extra (I haven't priced those, so no number). Wix is cheaper when you want everything bundled and small; WordPress is cheaper when you can run it yourself and keep add-ons lean.
How much would a small site cost over a year?
Rough arithmetic from the prices above, mine, before tax, domain renewal and any paid app or plugin:
- Wix Core, yearly billing: $29 × 12 = $348 as listed, payments and scheduling included
- WordPress.com Business, yearly billing: $25 × 12 = $300, with plugin install, SFTP/SSH and Git
- WordPress.com Premium, yearly billing: $8 × 12 = $96, with payment buttons
- Self-hosted WordPress on Hostinger Premium: $10.99 × 12 = $131.88 a year once the 48-month promo ends, plus any themes and plugins you buy
The WordPress rows are cheaper if you handle the technical side yourself. The Wix row costs more on paper but includes support and no maintenance, which is a cost you'd otherwise pay in time.
What does the Wix versus WordPress choice look like as a checklist?
Run through these in order. Each answer pushes you one way, and if three or four point to one side, that's your answer.
1. Will you ever need to move platforms? If yes, lean WordPress. If you're certain you won't, either works.
2. Do you need a plugin that exists only for WordPress (a membership tool, a niche CRM connector, a booking engine you already use)? Then WordPress. If Wix's native booking and store cover it, Wix works.
3. Who maintains it? Nobody technical: Wix or another hosted builder. You, or a developer who enjoys it: WordPress.
4. How much custom code? A snippet or two suits either. Heavy backend logic points to WordPress or a code-first tool; Velo is real but quota-bound.
5. Do you need paid memberships, a newsletter or bookings in the same login? A hosted bundle is easier. On WordPress each is usually a plugin.
6. How many content items will you store? A few thousand won't touch Wix's caps. Tens of thousands means reading the item limit for the plan you'd actually buy.
7. How sensitive are you to transaction fees? Price the processor fees for your card mix and country, then compare each platform's fee ladder.
8. Have you rehearsed your exit? On Wix, check what comes out before you build big: CMS collections can export to CSV, blog posts officially can't. On WordPress, do a test export and import on a staging site.
9. Does your agency or freelancer already know one of them? A familiar tool beats a theoretically better one.
10. Who answers at 2 a.m. when the site is down? Wix gives you one support line. WordPress gives you your host's line and your own debugging.
My tiebreak: a salon or consultant with no developer who wants a site, bookings and a blog should probably start hosted. A publisher, agency or anyone treating the site as a long-term asset should start on something exportable.
Which other options are worth a look?
If you're comparing Wix and WordPress, you're probably also half-considering a few more. These are in no ranking order, and the prices below are the ones I read on 2026-10-11.
WordPress.com versus WordPress.org
These get confused constantly. wordpress.org is the free GPL software you host yourself. wordpress.com is a hosted service that runs WordPress for you. The free plan there has 1 GB, shows ads and doesn't allow plugins. Paid plans, in USD with yearly versus monthly billing, are Personal at $4 yearly or $9 monthly (6 GB), Premium at $8 or $18 (13 GB), Business at $25 or $40 (50 GB) and Commerce at $45 or $70 (50 GB). Paid plans can install plugins. It gives you the WordPress content model with a hosted safety net; the trade is working inside WordPress.com's plan rules rather than a bare server.
Squarespace
Squarespace is the nearest Wix rival for design-led sites. In USD it lists Basic at $16 a month billed yearly ($21 billed monthly), Core at $23 ($32), Plus at $39 ($48) and Advanced at $99 ($119), with a 14-day trial and no card required. Hosting, a free SSL certificate and 24/7 support are included. Its fee ladder is the thing to read: online-store sales cost 2% on Basic and 0% above it, and memberships and digital content cost 7%, 5%, 1% and 0% going up the plans. Leaving is limited: its export gives a WordPress-compatible XML file covering one blog page and a few page types, but not store pages, products, drafts or custom CSS.
Webflow
Webflow is a visual builder with design control closer to hand-written CSS. Site plans are Starter (free, a webflow.io domain, a limited CMS and 2 static pages), Basic at $15 a month billed yearly (a custom domain, 300 static pages, 10 GB bandwidth, pitched at sites that don't need a CMS) and Premium at $25 billed yearly, which adds the full Webflow CMS. Code export sits on the Workspace Core plan at $19 a month billed yearly, and Webflow's pricing page warns that dynamic content has to be exported collection by collection and that forms stop working. Site plans and workspace plans are listed separately, so the real total can be the sum of both. I'd also expect a steeper learning curve than Wix (my opinion, not a measurement).
Ghost
Ghost is a publishing platform rather than a site builder. Ghost(Pro) starts at $15 a month billed yearly for Starter (1,000 members, one staff user), $29 for Publisher (three staff users, custom themes, 0% transaction fees) and $199 for Business (10,000 members, 15 staff users). It's MIT-licensed on GitHub, so self-hosting is possible, though the README lists the managed service first. It's excellent for writers and newsletters. It isn't a brochure-site or bookings tool.
VeloCMS
VeloCMS is a hosted blog-first platform that also does newsletters, memberships and a small store. Pricing is Pro at $9 a month, Business at $29 and Agency at $69. Pro includes a custom domain, a visual page builder and a membership paywall, within 5 GB of storage and 5,000 subscribers on one site. There's no free plan: every plan starts with a 14-day trial that takes a card at signup and charges nothing until day 15. It takes no platform fee on sales, though payment-provider fees still apply. The weak points are plain. It's young, the marketplace shows "57 free integrations" that are all first-party, there are 40 themes, and the developer page says it's managed SaaS only with third-party plugin development not open yet. Content exports as JSON or CSV, and its Wix import guide reads your public site, 30 pages per run, as drafts.
Who is each option best for?
- Wix: owners who want a good-looking site, a blog, bookings or a small shop on one bill, edited by non-developers, and who don't expect to migrate.
- WordPress.org (self-hosted): people who need a specific plugin, want full ownership of the files and database, and will keep it patched themselves or pay someone to.
- WordPress.com: people who like the WordPress ecosystem and want the hosting handled, and can live with plan-based rules.
- Squarespace: design-led small businesses that want a polished site and simple editor and accept a closed platform and fees on sales.
- Webflow: designers who want precise layout control and structured CMS collections, and are fine paying for both a site plan and a workspace.
- Ghost: writers and publishers whose business is the newsletter and paid membership.
- VeloCMS: solo creators and small teams who want blog, newsletter, membership and a small store in one hosted login and don't need a large plugin catalog.
When is each option the wrong choice?
Wix is the wrong choice when portability matters, when you need unusual backend behavior, or when your content volume could outgrow the collection caps for the plan you can afford. It's also awkward if you expect a deep search-engine tuning project, though as noted its built-in controls are better than the reputation suggests.
WordPress is the wrong choice when nobody wants to be responsible for updates, backups and plugin hygiene, or when a client will be editing unsupervised and could break things. The vulnerability numbers above are the reason.
VeloCMS is the wrong choice if you need to self-host, rely on a particular WordPress plugin, need custom post types or structured data in git, or want a permanent free tier to experiment on. It's also the wrong pick for a salon-type business whose revenue depends on a deep booking system, because it's a blog-first product with a short integration list, and I haven't compared its integrations against Wix Bookings feature by feature.
And if your current site works, staying put is often the cheapest thing you can do. Migration costs URLs, plugin data and retraining before it returns anything.
FAQ
Is Wix easier than WordPress?
For a first site, usually yes. Wix bundles hosting, a certificate, support and an editor, and its new Harmony editor adds an AI agent for prompts. In my opinion WordPress is only as easy to start as your host's installer and your familiarity with a page builder (Wix help: Harmony).
Is Wix cheaper than WordPress?
Depends how you count. Wix's paid plans run from $17 to $159 a month on yearly billing (Wix plans); WordPress software is free and hosting can start under $3 a month on a long promo that renews higher (Hostinger). WordPress is cheaper on paper if you maintain it yourself.
Can I move my Wix site to WordPress?
Not as a whole. Wix says its sites must run on Wix's servers, and its help center says blog posts can't currently be exported to other platforms. You can export CMS collections as CSV and rebuild the design on the new platform, so plan the move as a rebuild (Wix help).
Is WordPress safer than Wix?
Neither is "safe" by default. Patchstack found 91% of 2025's new WordPress vulnerabilities in plugins and 6 in core, so a lean, updated install is fine and a neglected one isn't (Patchstack). Wix removes the plugin-patching job but you're trusting Wix's own security claims (Wix blog).
Can Wix handle SEO as well as WordPress?
For most small sites, yes: it lists editable robots.txt, canonical tags, structured data, slugs and 301 redirects (Wix SEO features). WordPress offers more headroom through plugins and server control, and whether you need it depends on how unusual your SEO plan is.
Does Wix charge transaction fees?
The plans page I read lists none, but the processor does: in the US, Wix Payments lists 2.9% plus $0.30 for standard cards (Wix Payments fees). Squarespace adds platform fees on cheaper plans (Squarespace pricing).
What if I only need a blog?
A blogging-focused tool may fit better than either. Ghost is the newsletter specialist, WordPress has the deepest blogging ecosystem, and VeloCMS is another option if you also want memberships or a store. Test with a real post before paying.
Last verified: 2026-10-11.
Sources (all read on 2026-10-11)
Wix: plans · Wix Payments fees · export and external hosting · blog export · custom code · Velo · Harmony editor · free site · collection limits · SEO features · App Market · hosting article · premium plans article
WordPress: about and license · plugin directory · Yoast SEO listing · recommended hosts · upgrade and auto-update docs · export screen docs · WordPress.com pricing · Hostinger WordPress hosting
Security and usage data: Patchstack 2026 report · W3Techs CMS usage
Alternatives: Squarespace pricing · Squarespace export · Webflow pricing · Ghost pricing · Ghost on GitHub · VeloCMS pricing · VeloCMS marketplace · VeloCMS themes · VeloCMS for developers · VeloCMS Wix import guide · VeloCMS WordPress import guide
<!-- fact-check 2026-10-11 (independent checker; every claim re-read against the vendor page today, US view where currency applies):
CORRECTIONS (10)
1. Wix prices: "$17.77 / $29.77 / $39.77 / $159.77, USD for reference only, Price includes GST" -> "$17 / $29 / $39 / $159, yearly billing, before tax". The original was read from a non-US page (GST-inclusive converted figures). Sources: wix.com rendered for a US visitor (Wix's own Prices collection record "US": light 17, core 29, business 39, businessElite 159, currency USD) and wix.com ("range from $17 to $159 per month when billed annually"). Wording about GST/"for reference only" removed; added "prices and currency vary by location and Wix sometimes promotes discounts".
2. Wix Core annual arithmetic: "$29.77 x 12 = $357.24" -> "$29 x 12 = $348". Source: same as 1.
3. FAQ "Is Wix cheaper": "$17.77 to $159.77" -> "$17 to $159". Source: same as 1.
4. Squarespace prices: GBP (12/17/29/79; monthly 16/24/36/89) -> USD annual 16/23/39/99, monthly 21/32/48/119 (read by switching the page currency to USD on squarespace.com). Fee ladder (2%/0%/0%/0% store, 7/5/1/0% memberships), 14-day trial, no card required were re-confirmed unchanged.
5. Squarespace export link: support.squarespace.com (404 on HEAD) -> support.squarespace.com (resolves 200). Export inclusions/exclusions re-confirmed.
6. Ghost(Pro) Starter: "$18 a month billed yearly" -> "$15 a month billed yearly". Source: ghost.org (Publisher $29 and Business $199 unchanged).
7. Webflow: removed "CMS items ranging from 50 on Starter up to 20,000 on Premium" (not on the current pricing page; unverifiable) -> Starter has a limited CMS, Premium adds the full CMS, Basic is for sites without a CMS. Source: webflow.com.
8. Webflow export: "dynamic content (CMS items and pages) can't be exported" -> "dynamic content has to be exported collection by collection and forms stop working" (that is what the pricing-page FAQ now says). Workspace Core $19/mo billed yearly with code export re-confirmed. Source: webflow.com.
9. W3Techs: the old sentence mixed two bases (58.6% known-CMS share vs "6.1% for Wix" etc. presented as if all-websites). Now stated explicitly: WordPress 40.1% of all sites = 58.6% of known-CMS sites; Shopify 7.8%, Wix 6.1%, Squarespace 3.6%, Webflow 1.2% on the known-CMS basis (Wix 4.2% of all sites). Source: w3techs.com (numbers themselves were correct).
10. WordPress export: "Media files are a separate download, and a theme's design doesn't travel in that file" was not in the cited docs -> reworded to what the docs say (lists content types, silent on media/theme). Source: wordpress.org.
Also: the Wix SSL claim is now attributed to the hosting article (wix.com: "Free HTTPS and automatic SSL certificate installation"), since the premium-plans article does not mention SSL; two "in my experience" lines (Webflow learning curve, WordPress ease of start) relabelled "my opinion"; VeloCMS entry trimmed (155 -> ~145 words) for length parity with neighbours (GEO standard 2.6); "newsletter and membership paywall" wording kept to what velocms.org/pricing lists.
VERIFIED UNCHANGED (source read today): Wix Payments US fees 2.9%+$0.30 / Amex 3.7%+$0.30 / PayPal 3.49%+$0.49 / Afterpay+Klarna 6%+$0.30 / cross-border 1.5% / conversion 1% (support.wix.com/en/article/service-fees-for-wix-payments; no plan dependency stated); Wix export, blog-export, free-site, collection-limit, custom-code, Velo, Harmony/Aria, CSV-export, SEO (500 URL redirect import), App Market sub-counts (22/35), hosting-blog claims (99.99%, 200+ CDN nodes), premium-plans blog; WordPress.com plans $4/8/25/45 yearly and $9/18/40/70 monthly (public-api.wordpress.com plans endpoint, USD), 1 GB free plan, 60,000+ plugins, Commerce 0% transaction fees, SFTP/SSH/Git on Business; WordPress.org about (GPLv2+), hosts (Pressable, Bluehost, Hostinger), plugins page (no total), Yoast 10+ million, upgrade docs (5.6 auto core updates), Hostinger Premium $2.99 / 48 months renewing $10.99; Patchstack 11,334 / 91% / 9% / 6 core / 46% / 5 hours; Ghost MIT + README lists Ghost(Pro) first; VeloCMS pricing page ($9/$29/$69, 14-day trial, card at signup, no charge until day 15, 5 GB / 5,000 subscribers on Pro, 40 themes, 0% platform fee on reader purchases), marketplace "57 free integrations" (all "by VeloCMS Team"), for-developers (managed SaaS only, third-party plugin dev not open, JSON or CSV export), Wix import guide (30 pages per run, drafts), src/lib/plans/limits.ts (no free plan, 14-day trial). No "free plan" wording about VeloCMS remains except the statement that there is none. All 35 links resolve (200 on GET).
COULD NOT FULLY VERIFY / CAVEATS: (a) A third-party site (usagepricing.com) claims Wix raised Light from $17 to $19 on 2026-08-27; Wix's own plans page, its Prices record (updated 2026-08-30) and its blog still show $17, so the article follows Wix. Re-check Light before publishing if the date slips. (b) Wix's WebFetch view showed "Save 50%" promo labels on some tiers; the US page view showed none, so list prices are used and the article tells readers promotions may apply. (c) "Hosting, SSL and 24/7 support included" for Squarespace comes from its pricing-page FAQ text, not a plan-card line. (d) The Wix plan-feature tiering (payments from Core up) was read from the plan cards, which carry no tooltips; the Wix compare-features table was not opened. -->
What is the best free CMS platform?
The best free CMS depends on which kind of free you mean. If you're happy to run a server, self-hosted WordPress is the safest default and Ghost is the nicer place to write. If you want no server and no monthly bill at all, Hugo or Astro on a free static host is the cheapest setup that really works. If you want a no-code start, the free tiers of WordPress.com and Wix will get you online, with real limits attached: no custom domain on either, and a banner ad on Wix.
Disclosure: I work on VeloCMS, and it's in the list. It sits last, gets an entry about the size of its neighbours' (weaknesses included), and I'll say plainly that it is not the pick for a zero-budget reader. It has no free plan, only a 14-day trial.
Last verified: 2026-10-10.
A note on method. Every price, limit and licence below was read from the vendor's own page or repository on 2026-10-10. Where a page wouldn't load or wouldn't show a clear number, I say so instead of quoting a number from memory. Wix's paid prices rendered in a currency I couldn't pin down, so I've left them out. Opinions are marked as mine.
What does "free CMS" actually mean?
Three different things hide behind the phrase, and mixing them up is how people end up with a surprise bill or a site they can't grow.
The first is free software that you host yourself. The licence costs nothing and you can run it anywhere, but you supply the server, the database, the updates and the backups. WordPress.org, Ghost, Strapi, Decap CMS, Hugo and Astro all live here.
The second is a free tier of someone else's hosted product. Nothing to install, nothing to patch, and the vendor decides the walls: a subdomain instead of your own address, a page cap, a bandwidth cap, a banner, a feature that's switched off until you pay. WordPress.com, Wix, Webflow and Sanity are in this group.
The third is the stuff that stays on the bill whichever of the first two you pick: the domain name, email delivery, backups, monitoring, and your own hours. That one has no logo and no pricing page, which is why it gets forgotten.
Keep those three apart and the question gets easier. "Best free CMS" for a hobby blog and "best free CMS" for a client who will pay you for the site are different questions with different answers.
Is a free CMS really free?
Rarely, once the site is live. The software can be zero. The things around it usually aren't.
Ghost does something unusual for a vendor: it prints the arithmetic. Its hosting documentation (accessed 2026-10-10) says self-hosting starts "from $10/mo" for a server with at least 1 GB of memory, then lists services you'd add separately: a CDN and WAF from $20/mo, newsletter delivery from $15/mo, analytics from $10/mo, backups from $5/mo and an image editor from $12/mo. Ghost sells the managed version, so read the list with that in mind, but the categories are the useful part. A self-hosted anything needs a server, a way to send email, a place to put backups and something to tell you when it falls over.
Domains are the other classic. A few vendors throw in a first-year domain: WordPress.com says all paid plans include a free custom domain for the first year, Wix's domains help page offers a one-year domain voucher with select yearly or multi-year Premium and Studio plans, and Ghost's pricing page carries a one-year .link domain promotion with the line that registrations "renew at standard rates after the first year." If you'd rather buy it yourself, Cloudflare says its registrar does not mark up domain prices, charging only what registries and ICANN charge (the page gives no example price, so check the live number for your extension).
Email is where free tiers run out fastest. Resend's free plan allows 3,000 emails a month with a 100-a-day cap and three domains, and its Pro plan starts at $20 a month for 50,000. Brevo advertises a free-forever plan that sends up to 300 emails a day once your account is approved. Those are fine for a form notification or a small list, and tight for a weekly newsletter that's growing.
Then there's time, which I'd call the biggest line item and which no vendor prints. A self-hosted stack means you are the on-call engineer for your own blog. That's a fair price for some people (it's how many of us learned) and a poor one for a small business owner who just wants the contact form to work. The free tiers of hosted products flip the trade: your time is safe, your options are narrow.
Which free CMS is best for a blog?
Here's a quick map before the detail. Each line is a situation, then my pick and the reason.
- You want the biggest ecosystem and don't mind running it: WordPress.org, because plugins, themes and freelancers are everywhere.
- You want a clean writing tool and a newsletter, and can run Node: self-hosted Ghost.
- You're a developer and want the cheapest, fastest-loading site: Hugo or Astro on a free static host.
- You want an editing screen on top of a static site: Decap CMS, if you're comfortable with Git.
- You want zero setup and zero cost, and accept the walls: WordPress.com free or Wix free.
- You're building something custom and need an API: Strapi (self-hosted) or Sanity's free plan.
- You want blog, newsletter, membership and shop under one login and can pay: a paid all-in-one, which is where VeloCMS sits.
Now the options one at a time, in the order I'd reach for them on a zero budget.
The options, one at a time
WordPress.org (self-hosted WordPress)
WordPress is licensed under the GPL (version 2 or later, per wordpress.org), which gives you the freedom to run, study, change and redistribute it. That's the "free" you can bank on: nobody can switch it off or put it behind a plan. Its requirements page recommends PHP 8.3 or greater, MariaDB 10.11 or greater or MySQL 8.0 or greater, Apache or Nginx, and HTTPS.
The strength is the ecosystem. If a problem exists, someone has probably written a plugin or a tutorial for it, and finding a person who knows the system is easy. The weakness is that the ecosystem is also the maintenance surface. You pick the host, you keep PHP and the database current, you choose plugins and vet them, and you test restoring backups. I haven't priced hosting here because the range is wide enough that any single number would be a guess. Budget something for it, and don't confuse this with WordPress.com, which is the next entry and a different product.
WordPress.com (the hosted free plan)
The free plan gives you a WordPress.com subdomain, 1 GB of storage, unlimited pages and posts, and basic stats for the last seven days. The pricing page also lists what it doesn't include: a custom domain, plugin installation, ad-free browsing for visitors, and premium themes.
So it's a decent place to try writing and a poor place to build a brand. The first paid step is Personal at $4 a month on annual billing ($9 month to month), which brings 6 GB and a free custom domain for the first year. Business is $25 a month on annual billing ($40 monthly) with 50 GB, and Commerce is $45 on annual billing. My read is that the free plan works better as a long trial than as a place to stay.
Ghost (self-hosted, MIT-licensed)
Ghost's repository is released under the MIT licence, and the project describes itself as independent technology for publishing, memberships, subscriptions and newsletters. If you like a focused editor and want newsletters and paid memberships in the core product, that focus is its appeal.
Be clear about what "free" means here. Ghost(Pro), the hosted service, has no free plan: Starter is $18 a month billed yearly for 1,000 members and one staff user, Publisher is $29, and Business is $199. The page shows "Try for free" buttons, which I read as a trial (I couldn't find a trial length on the page, so check before you sign up). The free route is self-hosting, and the docs list an Ubuntu LTS server, Node.js 22, MySQL 8, NGINX and at least 1 GB of memory, plus the extra services priced above. Updates and security patches are yours. Great for a technical writer, a real chore for everyone else.
Strapi (open-source headless CMS)
Strapi's Community edition is MIT-licensed and, per its pricing page, includes role-based access, REST and GraphQL APIs and unlimited entries and API calls. The page says what stays out of the free edition: Strapi AI, Live Preview, Releases and Content History. Growth, the first paid tier, is $45 a month for three seats.
Think of it as a backend, not a website. You get an admin panel and an API, and you build the front end yourself. The CLI install docs require an active or maintenance LTS version of Node, default to SQLite, and list MySQL, MariaDB, PostgreSQL and SQLite as supported databases, each with a recommended version. They also note that self-hosted content sits by default in a SQLite file in a .tmp folder, which is a good reason (my view) to set up a real database before you have real content. I found no free tier on Strapi Cloud; its plans start at $35 per project per month. It's a strong pick if your project is more app than blog, and overkill for a personal site.
Hugo and Astro (static site generators)
These are free in the strongest sense: tools that turn files into a folder of HTML, which any static host serves. Hugo is Apache-2.0 licensed per its GitHub repository and calls itself the world's fastest framework for building websites (a vendor claim, not my measurement). Astro is MIT-licensed, describes itself as a framework optimized for fast, content-driven sites, and ships adapters for Netlify, Vercel, AWS and other hosts.
What you get is a site that can sit on a free host, a version history for free because it's all in Git, and almost nothing to patch at runtime. What you give up is an admin screen. There's no login for a non-technical editor, no built-in forms, comments or members, and anything dynamic means bolting on a third-party service. For a developer's blog or documentation, I'd pick this first. For a client who wants to edit their own pages, I wouldn't, at least not without the next entry.
Decap CMS (a Git-based editor for static sites)
Decap CMS is the answer to "can a static site have an editing screen?" It's MIT-licensed and describes itself as open source content management for your Git workflow: content stays in your repository, and the editor writes commits to it. It works with generators such as Hugo and Jekyll.
The catch is in the plumbing. The open-source version asks you to bring your own authentication and backend, so you'll configure a Git provider and a login path yourself. The site mentions a paid extension, Decap Turbo, for hosted authentication and roles, which is useful to know about and not something I've priced. Decap suits a small team that already lives in Git and wants an editor for the one or two non-developers. It's less comfortable if "Git" is a foreign word to your editor.
Wix (free plan)
Wix offers a free plan with no credit card required, a drag-and-drop editor and a template library. The free site lives at a Wix-branded address instead of your own domain. Wix's support page on the banner ad says a free site includes a Wix banner ad at the top and a Wix favicon in the browser tab, and that removing the banner needs a Premium plan.
It's the lowest-effort way for a non-technical person to get a visual site online, and for a first look at whether you want a website at all, that's useful. It's a weak choice for a serious blog, because the cap isn't a feature cap but an identity cap: your readers see Wix's address and Wix's ad. Wix says select yearly Premium plans come with a free domain for a year. I won't quote the plan prices, because the page showed them in a currency and tax format I couldn't confirm.
Webflow (Starter plan)
Webflow's free Starter site plan, per its pricing page, gives you a webflow.io domain, a limited CMS, 2 static pages, 1 GB of bandwidth and 50 form submissions. That's a sandbox. The next step, Basic, is $15 a month billed yearly and adds a custom domain, 300 static pages and 10 GB of bandwidth and is pitched at simple sites that don't need a CMS, while Premium, the plan that adds the CMS, is $25 a month billed yearly.
Webflow is a design tool first. If you care about pixel-level control without writing code, nothing else here comes close, and a free Starter site is a good place to find out whether you can stand the learning curve (my view). I couldn't open Webflow's help article on plan limits (it returned a 403), so I'm quoting only what the pricing page lists. A blog on this free plan would hit the page and CMS ceilings fast.
Sanity (free plan)
Sanity is a hosted content backend, and its free plan has the most room of the hosted ones here. The page lists up to 20 user seats (administrator and viewer roles only), 2 public datasets, 10,000 documents, 1M API CDN requests and 250,000 API requests a month, plus 100 GB of bandwidth and 100 GB of asset storage. Studio hosting is free, and the page I read mentions no restriction on commercial use. Growth is $15 per seat per month.
The free plan has fences worth knowing about: both datasets must be public, draft change review is limited to three days, and full history isn't included. The deeper catch is the same as Strapi's: Sanity stores content and serves an API, and somebody has to build the website. Pair it with Astro or Next.js and a free static host and you have a capable free stack, assuming you can build it. (Storyblok has a free Starter plan on its pricing page, and Contentful's help center has a page about its Free plan, but Contentful's pricing page rate-limited my requests, so I'm not quoting numbers for it.)
VeloCMS (no free plan, 14-day trial)
VeloCMS is a hosted all-in-one: blog, newsletter, membership and a small store under one login. It has no free plan. Pro is $9 a month, Business $29 and Agency $69, in USD, and every plan starts with a 14-day trial. The pricing page says a card is collected at signup and nothing is charged until day 15; cancel before then and you pay nothing.
Pro lists a custom domain, a visual page builder with 70+ blocks, membership and paywall with a 0% platform fee, a newsletter, e-commerce and a WordPress migration wizard, within 5 GB of storage, 5,000 subscribers and one site. Reader payments run through your own Stripe or PayPal account, so their fees still apply.
Now the limits. The developer FAQ says the application is proprietary and managed-only: no self-hosted edition and no source access. It also says third-party plugin and theme development isn't open yet. It's a much younger product with a smaller community than WordPress. For a zero-budget reader it is the wrong answer: every other option above can be free, and this one can't.
Where can you host a free site for free?
Static sites (Hugo, Astro, Decap's output) can sit on free hosts, and the differences between the hosts matter more than the marketing suggests.
Cloudflare Pages lists, for its free plan, 500 builds a month, one build at a time, up to 20,000 files per site, a 25 MiB cap per asset, 100 projects per account and 100 custom domains per project. Its Functions pricing page says static asset requests are free and unlimited on free and paid plans, while Functions requests count against the Workers Free quota of 100,000 requests a day. For a blog, the 25 MiB asset cap is the one you'd hit, with a video.
Netlify's free plan is $0 and credit-based: 300 credits a month, with custom domains and SSL, unlimited deploy previews and a global CDN. The page shows a Personal plan at $9 a month (1,000 credits) and Pro at $20 a month (3,000 credits). The exact conversion from credits to traffic isn't on the page I read, so measure your own usage before you rely on it.
GitHub Pages has a 1 GB size limit on published sites, a soft 100 GB-a-month bandwidth limit and a soft limit of 10 builds an hour. The part that matters for a business: its docs say Pages isn't intended for or allowed as a free hosting service to run your online business, an e-commerce site, or any site primarily directed at commercial transactions or commercial SaaS. A personal blog is fine. A shop isn't.
Vercel's Hobby plan is free and roomy (100 GB of fast data transfer, a million CDN requests), but its docs say it restricts users to non-commercial, personal use only. If the site earns money or belongs to a business, read that before you deploy.
A practical zero-cost stack, then: Astro or Hugo for the site, Git for history, Cloudflare Pages or Netlify for hosting, a domain you register yourself, and Decap or Sanity if someone non-technical must edit. The cost is a domain, plus the afternoon you spend wiring it.
Who is each option best for?
- WordPress.org: people who want the largest ecosystem and will own the hosting and patching.
- WordPress.com free: someone testing the waters who doesn't need a custom domain or plugins.
- Ghost (self-hosted): technical writers who want newsletters and members in one focused tool and can run a Node server.
- Strapi: developers building an app or custom site who want a self-hosted API with an admin panel.
- Hugo or Astro: developers who want a fast, cheap, version-controlled site with no admin screen.
- Decap CMS: small teams already in Git who need an editing screen for one or two non-developers.
- Wix free: a first, throwaway look at having a website, accepting a Wix address and a Wix ad.
- Webflow Starter: designers evaluating Webflow before paying, not real publishing.
- Sanity free: developers with a front end in mind who want a hosted content backend without a bill.
- VeloCMS: a solo creator or small team who has decided to pay for blog, newsletter, membership and shop in one login, and who doesn't need WordPress's plugin depth.
When is VeloCMS the wrong choice?
For this question, almost always. If the requirement is zero spend, VeloCMS is out: no free plan, and the trial takes a card. If you want to self-host or read the source, it's out, because there is no self-hosted edition. If you need a niche WordPress plugin, the integration list won't match WordPress's. If you want a typed schema in Git, or to build your own front end on a headless API, Sanity, Strapi or a static stack fits better. It starts to make sense when you've outgrown the free path and you're pricing the plugins, email tool and membership service you'd otherwise stitch together. Even then, price those alternatives before you decide.
FAQ
Is WordPress really free?
The software is, under the GPL. Hosting, a domain, backups and email aren't. WordPress.com also has a free hosted plan, with a subdomain, 1 GB of storage and no plugin installs (pricing).
Is there a free CMS with no ads and a custom domain?
Yes, but not as a hosted free tier from the website builders above. The route is self-hosted software or a static generator on a free host, plus a domain you buy yourself. Wix's free plan shows a banner ad and needs a plan upgrade before you can connect a domain, and WordPress.com's free plan shows ads and lists no custom domain.
Can I run a free CMS for a business?
Check each host's terms. GitHub Pages' limits page says Pages isn't for running an online business or e-commerce site, and Vercel's Hobby plan is restricted to non-commercial personal use. I only read the pricing and limits pages for Cloudflare Pages and Netlify, not their full terms, so check the commercial-use language yourself before relying on either.
What is the cheapest way to send a newsletter from a free stack?
A free email tier. Brevo advertises a free plan sending up to 300 emails a day, and Resend's free plan allows 3,000 emails a month with a 100-a-day cap. Either can be too tight as a list grows.
Does VeloCMS have a free plan?
No. It has a 14-day trial that takes a card at signup, with nothing charged before day 15, and paid plans from $9 a month (pricing).
Sources (all accessed 2026-10-10)
WordPress.org about · WordPress.org requirements · WordPress.com pricing · Ghost pricing · Ghost hosting docs · Ghost on GitHub · Strapi pricing · Strapi Cloud pricing · Strapi CLI install docs · Hugo · Hugo on GitHub · Astro · Decap CMS · Wix plans · Wix banner ad support page · Wix domains help page · Webflow pricing · Sanity pricing · Storyblok pricing · Contentful pricing · Cloudflare Pages limits · Cloudflare Pages Functions pricing · Cloudflare Registrar · Netlify pricing · GitHub Pages limits · Vercel Hobby plan · Resend pricing · Brevo pricing · VeloCMS pricing · VeloCMS for developers FAQ
Last verified: 2026-10-10.
<!-- fact-check 2026-10-10: re-verified against vendor pages fetched today. Corrections (10):
1 Strapi CLI docs: "recommend PostgreSQL, MySQL or MariaDB for production" -> supported DB list (MySQL, MariaDB, PostgreSQL, SQLite) with recommended versions. docs.strapi.io/cms/installation/cli
2 Wix domain: "same for its paid plans" -> voucher with select yearly/multi-year Premium and Studio plans. support.wix.com/en/article/about-domains
3 Webflow Basic: "explicitly no CMS" -> pitched at sites that don't need a CMS; CMS plan named Premium. webflow.com/pricing
4 GitHub Pages: restated to the docs' actual wording (free hosting for an online business/e-commerce/commercial SaaS not allowed); FAQ aligned. docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits
5 Ghost domain promo: added one-year .link. ghost.org/pricing
6 Strapi link strapi.io/pricing-self-hosted (redirect) -> strapi.io/pricing-cms.
7 FAQ: WordPress.com free also shows ads; Wix free needs an upgrade for a custom domain. wordpress.com/pricing, Wix about-domains
8 Storyblok free Starter plan now linked. storyblok.com/pricing
9 Strapi .tmp/SQLite advice marked as opinion.
10 VeloCMS entry trimmed 202 -> 179 words (Sanity 186); no claim changed. Verified vs messages/en.json, velocms.org/pricing and /for-developers: $9/$29/$69, 14-day trial, card at signup, no charge until day 15, no free plan, 5 GB / 5,000 subscribers / 1 site.
All other prices, limits and licences matched the vendor pages. All 28 original URLs resolve (Contentful pricing 429 = bot block, link kept). Unverifiable: Contentful free-plan numbers (429; none quoted); WordPress.com Free card read from the rendered page only; Wix paid prices deliberately omitted; dynamic prices can change after today. -->
Is Wix a CMS website?
Yes, with one caveat. Wix is a hosted website builder that includes a CMS, so a Wix site is a CMS website in the everyday sense: you edit content in a dashboard, store it in structured collections, and publish it through templates. The caveat is that the CMS is a feature of the Wix platform, not a separate piece of software you can install, host or swap out. The pieces are the Wix CMS (formerly called Content Manager), dynamic pages, the Wix Blog app, Velo for code (always on in Wix Studio, a Dev Mode switch in the regular Wix Editor), and a separate Wix Headless product for people who want their own frontend. Everything runs on Wix's servers, and Wix's own help center says so in as many words.
Disclosure: I work on VeloCMS, which shows up in the comparison section. It gets an entry about the same size as everyone else's, weaknesses included, and there's a section near the end on when it's the wrong pick.
Last verified: 2026-10-09.
Method: prices, limits and features come from the vendors' own pages, read on 2026-10-09, and each links to its source inline. Where a page gave no number, I say so. Opinions are marked as mine.
What does "CMS" mean, and how does Wix fit it?
A content management system separates what a site says from how it looks. You keep content (posts, products, listings) in a structured store, design templates once, and the system pours the first into the second. WordPress, Ghost and Webflow all work that way. So does Wix, once you use its CMS.
Where Wix differs is packaging. With WordPress.org you download software, put it on a host and run it. With Wix there's nothing to install and nothing to patch, because the builder, the database, the hosting and the editor are one product. Wix's developer docs describe the Wix CMS as "a built-in document-oriented database optimized to store and retrieve website content" that is "globally replicated" and "fully managed by Wix." Around it sit datasets (invisible components that wire page elements to data), dynamic pages and a Data API.
An independent tracker treats Wix as a CMS, too. W3Techs lists it in its content management category and put it at 4.2% of all websites in its October 2026 report, behind WordPress at 40.1% and Shopify at 5.4% (W3Techs, accessed 2026-10-09).
So the honest short version: Wix is a website builder first, and a CMS is one of the things inside it. If your question is "can I manage structured content on it," yes. If it's "can I treat it like a standalone CMS I can move around," mostly no, and the rest of this post is about why.
What can you actually manage with the Wix CMS?
You manage collections. A collection is a database table with typed fields, and you can view it as a table, list or gallery. Wix's CMS overview (accessed 2026-10-09) lists permissions on who can view, add, edit or delete, saved views, indexes, backups to restore earlier collection versions, and a sandbox for testing before you go live. It also supports CSV import and export.
The payoff is dynamic pages. You design one page layout and Wix generates a page per collection item, with the URL deciding which item to show. Wix's docs describe list pages, item pages and category pages, and the item-page version gives "every collection item a unique URL." The same overview gives a total pages quota of 298, up to 100 of them static. Each dynamic page you add counts as one page, but the page says dynamic pages can generate unique URLs for every item in a collection without consuming additional pages, so the item caps below are the limit that bites.
Wix's own apps use the same machinery: Stores and Bookings write their data to collections. These app collections are read-only in the CMS (edits happen in the app), can be exported to CSV, and don't count toward the item cap, per the data limits page.
Blogging is its own app. Wix Blog covers multiple writers with assigned roles, categories, hashtags, comments and likes, SEO and AI tools, translations, subscription options for members-only posts and blog analytics. Wix's developer docs say a blog can hold up to 100,000 posts, and a post can sit in up to 10 categories.
How many items can a Wix site hold on each plan?
This is the number people find out too late. From Wix's collection limits page (accessed 2026-10-09), items across your collections:
- Free site: 1,000 items on a regular account, 10,000 if the site belongs to a Wix Studio account
- Light: 1,500 items
- Core: 4,000 items
- Business: 20,000 items
- Business Elite and Elite: 10,000,000 items
Database storage is 10 GB across Light, Core and Business, and 100 GB on Business Elite and Elite (effective January 7, 2026). Each item is capped at 512 KB across its fields combined, excluding media files. One gotcha from the data feature limitations page: if you downgrade, nothing is deleted, but you can't add items until you're back under the lower quota.
What are Wix Studio and Velo for developers?
Wix sells more than one editor. The regular Wix Editor is for site owners, while Wix Studio is pitched at agencies and professionals building client sites. Velo is the development layer in both: Wix describes it as "an open development platform" where you "work in Wix's visual builder, add custom functionality and interactions using Velo's APIs, use your own tools, and enjoy serverless coding in both the front-end and backend." In the regular editor you turn it on with Dev Mode in the top bar. In Studio, per the Wix Studio coding docs, coding is always available.
Once you're in, the docs describe frontend and backend JavaScript (backend files become web modules your pages call), data hooks, scheduled jobs and custom routers, npm packages, outside calls with fetch, a secrets manager, HTTP functions for your own endpoints, and external databases connected through an adapter so they look like ordinary collections.
There are three places to write it: the built-in Code panel for small jobs, the browser-based Wix IDE (VS Code-based) for bigger projects, and a local setup where the Git integration and Wix CLI connect the site to GitHub so you can work in your own editor and publish from the command line. The Wix IDE can't be used while GitHub integration is on, though you can disconnect and switch back.
The compute side has real ceilings. On a free site, per Wix's free-site limits, you get one micro backend container (1 vCPU, 400 MB of RAM), 60 backend requests a minute and a 14-second timeout. Data requests are capped at 1,000 reads and 60 writes a minute, and the data limits page adds that each include() in a query counts toward the request quota (its own example query counts as 3 reads). Paid plans raise these, but neither Wix's data-limits nor compute-limits page prints per-plan numbers for requests, only the free-tier table above, so check Wix's plan comparison before you size a project.
My take: Velo is a real development environment, handy for bolting a custom form handler onto a site a non-developer will keep editing. It's not the place to port a large application, and the quotas say so.
Can you use Wix as a headless CMS?
Partly, and it depends what you mean by headless. Wix Headless lets you "build a custom frontend on any stack you choose and power it with production-ready Wix business APIs," and the list of solutions it names includes eCommerce, bookings, events, memberships, CMS and blog.
There are two routes. Wix recommends Wix-managed headless, where Wix hosts and runs your frontend; build it with Astro and you also get automatic authentication, SEO support and extensions. In self-managed headless you host wherever you like, call Wix from the JavaScript SDK or REST API, and handle authentication yourself (so does any managed setup that isn't Astro). Wix says Headless is free to start, with a premium plan unlocking payments, custom domains and more.
For a blog, the Posts API is the relevant piece. The docs list endpoints to get, list and query posts, and listing returns up to 100 published posts per request. Reads need the Read Blog permission scope, and you can create admin API keys in the API Keys Manager, scoped to all sites or only the ones you select (how to generate one).
What it isn't: a classic content API you can point a static site at and forget. Your content still lives in Wix, Wix's data quotas apply to anything making data requests, and you depend on Wix's auth model. If you want Git-backed Markdown that runs anywhere, that's a different category of tool.
What are the real limits of Wix as a CMS?
Can you export or move a Wix site?
This is the limit that matters most. Wix's help article on exporting says it plainly: "Since Wix is a SaaS solution, your site must run on Wix's servers." You can connect a domain you bought elsewhere (paid plan needed), and the article says the content belongs to you, but the site itself stays on Wix. There's no zip of HTML, CSS and a database to carry to another host.
For content, the picture is patchy:
- Blog posts: Wix's help center lists exporting posts to other platforms as a feature request and says it is currently not possible. The blog's RSS feed shows only the latest 20 posts, so it won't carry an archive.
- CMS collections: they do export. From More Actions you choose Export to CSV, and you can export everything (including hidden and system fields), only the rows matching a filter, or just the structure. Non-Latin text is UTF-8 and time fields come out as UTC+0, per Wix's export guide. That page doesn't say how media fields or reference fields are written out, so test with a small collection before you rely on it.
- Images and video: the Media Manager lets you download files you uploaded, several at a time within a folder, but not everything in one click, and free Wix and Shutterstock media can't be downloaded.
- Redirects: the redirect manager imports up to 500 redirects per CSV file and exports your whole list.
Getting in is easier than getting out. Wix's WordPress importer takes up to 5,000 posts per run, from a blog URL or an XML file, with an overall Wix Blog cap of 100,000. It brings text, images, video, original dates, alt text and categories. It skips tags, author names, comments, forms, plugins, custom code and attached documents. Notice the asymmetry.
What about SEO controls?
Wix's SEO features page (accessed 2026-10-09) lists an editable robots.txt, automatic structured data for products, events and blog posts that you can customize, editable canonical tags, title and meta description control, open graph tags, editable URL slugs on pages, product pages and blog posts, and a URL Redirect Manager for 301s. You work through Wix's settings screens and, if you code, Velo, not server configuration.
And performance?
Third-party field data is kind to Wix. The Core Web Vitals tracker at corewebvitals.io reports Q2 2026 mobile data in which 94.4% of Wix sites passed Largest Contentful Paint (p75 of 1.6 s) against 69.5% for WordPress (2.7 s) and 85.8% for Squarespace (2.2 s). Webflow and Ghost showed 100% from tiny samples (15 and 37 sites). It compares populations, not your site.
What other limits should you plan around?
Collection caps by plan, covered above, and a CMS request timeout of 5 seconds on most plans (10 seconds on Business Elite and Elite), so a slow aggregate query fails instead of limping along. Sandbox and live collections also have to be synced deliberately, and Wix warns a sync can't be undone. None of that hurts a brochure site or a modest blog. It bites where you'd normally reach for a real database.
How much does Wix cost right now?
From wix.com (accessed 2026-10-09), shown in USD per month with yearly billing paid in full at purchase:
- Free: $0, with a Wix address. Wix says a free site has 500 MB of storage and 1 GB of bandwidth, shows Wix ads, and needs an upgrade to connect a custom domain
- Light: $17.77 a month, 2 GB storage, 2 site collaborators
- Core: $29.77 a month, 50 GB storage, accept payments, basic eCommerce, scheduling and services, 5 collaborators
- Business: $39.77 a month (marked Recommended), 100 GB storage, standard eCommerce, 10 collaborators
- Business Elite: $159.77 a month, unlimited storage, advanced eCommerce, 100 collaborators
The page contradicts itself on tax. Each card says "Price includes GST," while the footnote says "Prices and currency vary by location. USD prices displayed for reference only," and adds that prices do not include applicable taxes, which are set by your billing address. A Wix blog post lists the same four plans as $17, $29, $39 and $159. So treat the cents as noise and read the number on the purchase page, which Wix says shows the final price before you pay. Wix offers a 14-day money-back guarantee, and selected yearly plans come with a one-year free domain voucher (first purchase of a site plan only, usable on a new domain or a domain transfer).
Wix Studio has its own site plans, named Basic, Standard, Plus and Elite. Wix's help page lists their allowances (CMS items match the numbers above, with 3, 5, 10 and 100 client accounts) but no prices, and the Studio plans page shows none in the HTML I could read. Wix's own blog posts disagree with each other on Studio prices (Basic at $12 in one, $19 in another), so I won't quote any.
When is Wix the right CMS, and when should you pick something else?
Wix is a strong pick when you want a good-looking site with a blog, bookings or a small shop, the owner edits it, and nobody wants to maintain hosting. It's weaker when portability matters, when your content runs into the item caps above, or when your workflow is code-first. Each entry below covers what it's good at, what it costs and what you give up.
WordPress.org (self-hosted)
wordpress.org is free software under the GPLv2 or later, built on PHP and MariaDB, and runs on whichever host you pick. That's the opposite of Wix on ownership: you hold the files and the database, with a huge plugin and theme ecosystem. The software is free, hosting is a separate bill from whichever host you pick, and patching and backups are yours.
WordPress.com
wordpress.com is managed WordPress. Its pricing page (accessed 2026-10-09) shows a Free plan with 1 GB and a WordPress.com subdomain, and USD prices that drop with longer commitments: Personal $9 monthly or $4 a month yearly, Premium $18 or $8, Business $40 or $25, and Commerce $70 or $45. It advertises more than 60,000 plugins on its paid plans. You get the WordPress content model with someone else running the servers, and the trade-off is working inside WordPress.com's plan and hosting rules. Its export produces a standard XML file with posts, pages, comments, categories and tags, but only links to your media, which you download separately from the media library.
Squarespace
Squarespace is the closest rival to Wix for design-led sites. It showed me pounds: Basic £12 a month yearly (£16 monthly), Core £17 (£24), Plus £29 (£36) and Advanced £79 (£89), with a 14-day free trial. Core and above add custom CSS and JavaScript. Fees apply on commerce: 2% on online-store sales at Basic and a 7% fee on digital content and memberships at Basic, falling to 0% at Advanced. Like Wix it's a closed platform, and its export is an XML file that covers one blog page with its posts and layout pages (plus gallery pages on 7.0 sites), but not store pages, product blocks, drafts or custom CSS.
Webflow
Webflow is the designer's tool with a real CMS behind it. As of 2026-10-09 it lists a free Starter plan (webflow.io domain, 50 CMS items), Basic at $15 a month billed yearly (no CMS), and Premium at $25 a month billed yearly with 20,000 CMS items and 40 collections. Prices are per site, in USD, plus tax. Its collections are the draw, though Wix's CMS has reference fields too, so the difference is mostly layout control rather than data modeling. The catch, in my experience: it has a steeper learning curve than Wix, Premium bandwidth comes in tiers from 50 GB up to 2.5 TB, and its code export is HTML and CSS only. The pricing page says dynamic content (CMS items and pages) can't be exported.
Ghost
Ghost is a publishing platform, not a site builder. Ghost(Pro) is $18 a month billed yearly for Starter (1,000 members, one staff user), $29 for Publisher (three staff users, custom themes, paid subscriptions, 0% Ghost transaction fee) and $199 for Business (15 staff users), all USD. It's MIT-licensed on GitHub, so you can self-host, and Ghost's hosting docs say self-hosters take on weekly updates and server maintenance. (The hosting docs say Ghost(Pro) is "from $15" a month, which I couldn't reconcile with the pricing page's $18, so I use the pricing page and you should check at checkout.) It's superb at writing, newsletters and memberships. It's not the tool for a bookings page or a general-purpose brochure site.
VeloCMS
VeloCMS is a hosted blog-first platform that also does newsletters, memberships and a small store. Its pricing page (accessed 2026-10-09) lists Pro at $9 a month, Business at $29 and Agency at $69, with yearly billing taking 20% off. Pro includes a custom domain, a block-based page builder, newsletter, membership paywall, ecommerce, 5 GB of storage and 5,000 subscribers; payments go through your own payment-provider account, so VeloCMS takes no platform fee, although the provider's processing fees still apply. Every plan is paid; the way to try it is a 14-day trial, with a card taken at signup and no charge until day 15. The trade-offs are real: it's young, the marketplace page advertises 57 free integrations (all first-party) and the themes page 40 themes, it's managed SaaS only with no self-hosted edition, and the developer page says third-party plugin development isn't open yet. Content exports as JSON or CSV.
How do you check what you can get out of Wix, or move off it?
Do this before you build anything big, and again before you leave.
1. Write down your live URLs. Pull the sitemap and note every blog post address exactly as it appears on the live site, because your new platform's redirect map starts from that list.
2. Export every collection to CSV, including hidden and system fields, and check how images, rich text and reference fields look.
3. Get the blog posts out by the least bad route. With no post export, your options are scraping the live pages, the 20-post RSS feed, or an API key plus the Posts API. I haven't run the API route on a large blog, so spot-check rich content for fidelity.
4. Download media you uploaded, folder by folder. Free Wix and Shutterstock images aren't downloadable, so plan replacements.
5. Export your redirect list, if you have one.
6. Plan the cutover. A redirect set up in Wix can only answer requests that reach Wix, so once DNS moves, the 301s must live on the new host. Keep the Wix plan alive until the new site is verified.
7. Accept what stays behind: the design, forms, bookings, member areas, app data and any Velo code that depends on Wix APIs. You'll rebuild those.
If you're heading to VeloCMS, its Wix import guide says it reads your public site, so no login or API key is needed. It brings posts from the blog feed, pages found via the sitemap, store products and images (re-hosted), up to 30 pages per run, arriving as drafts. Content behind a login, dynamic pages, and Wix-specific blocks such as forms, bookings and members areas can't be read that way, your design doesn't transfer, and the guide doesn't mention redirects or custom slugs. Other targets have their own import paths. Check the target's guide for the same list of what is skipped.
Who is each option best for?
- Wix: owners who want to design visually and run a blog, bookings or a small shop on one subscription, and don't plan to move later.
- WordPress.org: people who need a particular plugin, want full ownership and accept the maintenance.
- WordPress.com: people who like WordPress and want the hosting handled, and can live with the platform's plan structure.
- Squarespace: design-led small businesses that want a polished site and a simple editor, and are comfortable with a closed platform.
- Webflow: designers who want structured CMS collections with precise layout control, and will learn the tool.
- Ghost: writers and publishers whose business is the newsletter and paid membership, with no need for a store or a page builder.
- VeloCMS: solo creators and small teams who want a blog, newsletter, membership and small store in one hosted login, and don't need a big plugin ecosystem.
When is VeloCMS the wrong choice?
Sometimes. If you want a third-party app market, Wix has one and VeloCMS doesn't yet, since its integrations are all first-party. (It lists a booking integration, but I haven't compared it feature by feature with Wix Bookings.) If you want self-hosting or source access, it's out: the application is proprietary. If you want a permanent no-cost tier to test with, it has none, only the card-backed trial. If your site depends on a specific WordPress plugin, it won't run there. And if you want content as Markdown files in Git, that's a different category of tool.
FAQ
Is Wix a CMS or a website builder?
Both. Wix is a hosted website builder, and the Wix CMS is a feature inside it that stores structured content in collections and drives dynamic pages. The docs still say "CMS (formerly Content Manager)" in many places, which is the old name.
Is the Wix CMS free?
Wix has a free plan, and the free-site limits let you try most data and coding features, with a cap of 1,000 items on a regular free site. Collections grow with the plan: Light 1,500 items, Core 4,000, Business 20,000.
Can I export my Wix blog?
Wix's help center says exporting blog posts to other platforms isn't currently possible. You can export CMS collections as CSV, pull the latest 20 posts through RSS, or read posts through the Posts API with an API key. Download your media separately.
Can I use Wix with my own frontend?
Yes, through Wix Headless. Either Wix hosts your frontend (its recommended path, with automatic authentication if you use Astro) or you host it yourself and call Wix over the JavaScript SDK or REST API. Your content and business data still live in Wix.
<!-- fact-check 2026-10-09 (independent pass; every figure re-read from the vendor's own page; all 46 links return 200)
CORRECTIONS (old -> new, source):
1. "298 pages quota ... without saying how that interacts with per-item URLs, so test thousands of pages first" -> the CMS overview does say it: each dynamic page counts as one page, but item URLs consume no additional pages. support.wix.com
2. "Stores, Bookings and Events write their data to collections" -> "Stores and Bookings" (Events is not named on the page). support.wix.com
3. "Wix Studio is pitched at designers and developers" -> "agencies and professionals building client sites" (Wix: Studio is for Partners and agencies). support.wix.com
4. "a query with two includes costs three" -> "each include() counts toward the request quota (its own example query counts as 3 reads)"; the page's code sample is not rendered, so the two-includes arithmetic could not be confirmed. dev.wix.com
5. "Paid plans raise these, but I only found hard numbers for the free tier" -> confirmed and sharpened: neither the data-limits nor the compute-limits page prints per-plan request numbers (item #2 of the brief resolved: item caps per plan are published, per-plan request-rate caps are not). dev.wix.com
6. "Astro (the recommended framework)" -> Wix recommends the Wix-managed path; Astro adds automatic authentication, SEO support and extensions; any other setup handles auth manually. Also fixed in the FAQ. dev.wix.com and dev.wix.com
7. "Posts API calls authenticated by an API key or OAuth" -> not stated on the Posts API pages; replaced with what is stated: ListPosts returns up to 100 published posts per request, needs the Read Blog scope; API keys are created in the API Keys Manager. dev.wix.com
8. "counts against Wix quotas" (headless) -> "Wix's data quotas apply to anything making data requests". dev.wix.com
9. Sandbox "has to be synced deliberately" now sourced; added 10-second CMS timeout on Business Elite/Elite and "a sync can't be undone". support.wix.com
10. Wix tax note "my fetches disagreed" -> verified on one page: cards say "Price includes GST" while the footnote says prices exclude applicable taxes. Prices on wix.com/plans today: Light $17.77, Core $29.77, Business $39.77, Business Elite $159.77 (yearly, paid in full); the Wix blog lists $17/$29/$39/$159. Both kept, labelled. wix.com , wix.com
11. "most yearly plans include a one-year free domain voucher (first purchase only, new Wix domains only)" -> "selected yearly plans ... first purchase of a site plan only, usable on a new domain or a domain transfer" (the voucher can be redeemed on a transfer). support.wix.com
12. Wix Studio pricing: "Studio plans page didn't render prices" -> still true; additionally Wix's own blog posts conflict (Basic $12 vs $19), so no Studio price is quoted. wix.com , wix.com
13. Webflow: "Typed collections with reference fields make it more database-like than Wix" removed (Wix CMS has reference and multi-reference fields); "bandwidth is metered on a ladder of add-ons" -> Premium bandwidth tiers 50 GB to 2.5 TB; "dynamic CMS items and pages can't be exported" -> "dynamic content (CMS items and pages)". webflow.com , support.wix.com
14. Squarespace export: "layout pages and galleries" -> "layout pages (plus gallery pages on 7.0 sites)". support.squarespace.com
15. WordPress.com export: added that the media library is downloadable separately. wordpress.com
16. Ghost: $15 vs $18 resolved as far as possible: ghost.org/pricing shows Starter $18/mo billed yearly; docs.ghost.org/hosting still says Ghost(Pro) "from $15". Article uses $18 and states the mismatch. ghost.org , docs.ghost.org
17. VeloCMS "There is no free plan, only a 14-day trial..." -> "Every plan is paid; the way to try it is a 14-day trial, with a card taken at signup and no charge until day 15" (founder wording rule; card requirement matches the live pricing FAQ: "We collect your card at signup ... not charged until day 15"). velocms.org
18. VeloCMS "57 integrations" / "40 themes" -> attributed to the live pages ("57 free integrations" on /marketplace, "40 themes" on /themes, read 2026-10-09). The repo project map counts 58 built-in plugins and 62 themes, which is internal inventory, not what the public pages show.
19. VeloCMS wrong-choice paragraph: "If you need bookings or a bigger app market, Wix is the better product" removed (VeloCMS lists a booking integration; "better" was unsourced) -> third-party app market (Wix has one, VeloCMS has none yet); "typed content models in git, Webflow's reference fields" removed (Webflow is not Git-based and the VeloCMS content-model claim was unverifiable) -> Markdown-in-Git is a different category; "if you want a free tier ... it has none" -> "permanent no-cost tier".
20. "Who is it for / when to pick" intro: "large or relational content model" -> "content runs into the item caps" (Wix supports reference fields).
21. W3Techs: 4.2% Wix / 40.1% WordPress / 5.4% Shopify confirmed on the page today (heading "October 2026", updated daily); wording now says "October 2026 report". w3techs.com
22. corewebvitals.io: Q2 2026 phone data confirmed (Wix 94.4% / 1.6 s, WordPress 69.5% / 2.7 s, Squarespace 85.8% / 2.2 s, Webflow 15 sites and Ghost 37 sites at 100%). Unchanged. corewebvitals.io
23. Stray comma fixed in the CMS-timeout sentence; the numbered Sources list was removed because every entry is already linked inline (word-count budget; Posts API link added inline).
VERIFIED UNCHANGED: collection item caps per plan (Light 1,500 / Core 4,000 / Business 20,000 / Elite 10,000,000; free 1,000, Studio-account free 10,000), 10 GB / 100 GB database storage, 512 KB per item, 298-page quota (100 static), free-site compute and data table, Velo/Dev Mode/Studio coding/IDE/Git integration claims, Wix Blog caps (100,000 posts, 10 categories), export article quote, blog-export "currently not possible", RSS 20 posts, CSV export options (UTF-8, UTC+0), media download limits, redirects 500 per CSV, WordPress importer (5,000 per run; skips tags, authors, comments, forms, plugins, code, documents), SEO features, Studio plan allowances (1,500 / 4,000 / 20,000 / 10,000,000 items; 3 / 5 / 10 / 100 client accounts), WordPress.org GPLv2+ PHP/MariaDB, WordPress.com prices, Squarespace GBP prices and fees, Webflow Basic $15 / Premium $25 / 20,000 items / 40 collections, Ghost tiers and fees, VeloCMS plan prices/limits/20% annual toggle/0% platform fee/proprietary/no self-host/third-party plugins not open/JSON+CSV export, VeloCMS Wix import guide (public-site read, 30 pages per run, drafts, exclusions).
NOT VERIFIABLE ON AN OFFICIAL SOURCE (already labelled as such in the text): how Wix CSV export writes media and reference fields; Posts API blog export fidelity (author's untested route); Wix Studio prices; per-plan Wix data-request rates. -->
GoodSender vs Ghost vs Substack vs beehiiv: an honest comparison
If you want to earn from your writing or grow an audience inside a platform's network, pick Ghost, Substack or beehiiv. GoodSender is built for a different job: a blog on your own domain with free, reliable email to your subscribers. You write in the editor yourself, or let Claude or Codex help. Below is what the four have in common, what GoodSender adds, and where the others go further. Everything is based on each vendor's own pricing pages and docs, checked October 8, 2026.
What all four have in common
| | GoodSender | Ghost | Substack | beehiiv |
|---|---|---|---|---|
| Free way to start | Free on your own domain | Free if you self-host; Ghost(Pro) from $18/month | Free | Free plan, up to 2,500 subscribers |
| Your own domain | Yes, required to go public | Free custom domain | One time $50 | Paid plans |
| Site and search basics | Sitemap, RSS, canonical tags, no setup | Advanced SEO tools, RSS | SEO settings | Website builder on every plan |
| Email to subscribers | Yes | Yes | Yes | Yes |
The basics are covered everywhere: a site for your posts, your own domain, and email to subscribers. The differences are in cost, workflow and growth tools.
What GoodSender adds
| | GoodSender | Ghost | Substack | beehiiv |
|---|---|---|---|---|
| Email pricing | Free up to 100,000 emails a month, priced on emails sent | Rises with members: $105/month at 10,000 (Publisher) | Free; 10% of paid subscriptions | Rises with subscribers: $139/month at 10,000 (Lite) |
| Work with Claude or Codex | Official MCP and CLI: draft, preview, publish | No official MCP server | Official API is read only | Official MCP writes drafts; you publish in the app |
| Share on publish | LinkedIn, X, Bluesky, Mastodon, dev.to, Hashnode | Social web distribution | Video to YouTube and LinkedIn | Not listed |
Email that stays free as you grow. A weekly post to 10,000 subscribers is about 43,000 emails a month: free on GoodSender, and $10 a month once you pass 100,000 emails. Every subscriber confirms by double opt-in, inactive readers are asked to reconfirm after 120 days, and unsubscribes and complaints are suppressed automatically. Sending runs on Laneful, built by the team behind SendGrid.
AI help when you want it. Write everything yourself, or ask Claude or Codex to help with a draft, a preview or the publish step. beehiiv's MCP stops at drafts that "must be published from the app". Ghost has no official MCP server, and Substack's API only reads public data.
One publish, many places. A post can be announced on four social networks and republished on dev.to and Hashnode with a canonical link back to your blog. Pages need no search setup.
Where Ghost, Substack and beehiiv go further
| | GoodSender | Ghost | Substack | beehiiv |
|---|---|---|---|---|
| Paid subscriptions | No | Yes, 0% Ghost fee | Yes, Substack keeps 10% | Yes, 0% take rate |
| Discovery network | No | Recommendations, Explore network, referrals | Recommendations, Notes, app, referrals | Recommendation network, ad network |
| Comments and community | No | Comments | Comments, chat, Notes | Not listed |
| Themes and design | GoodSender templates; AI design generation in development | Marketplace and custom themes (Publisher and up) | Not listed | Website builder |
| Import existing posts | Subscribers only | Not listed | Posts and subscribers from 12+ platforms | Not listed |
Sources: Ghost pricing, Ghost on GitHub; Substack features, custom domain, SEO, API terms; beehiiv pricing, beehiiv MCP. "Not listed" means we did not find it on the vendor's pricing or feature pages.
Which one should you choose?
- Ghost: paid memberships, deep design control or self-hosting.
- Substack: readers through its network and app, at 10% of paid subscriptions.
- beehiiv: growth and monetization tools such as recommendation and ad networks.
- GoodSender: your own domain, free email to your readers, and AI help when you want it.
FAQ
Is there a free blog platform with email on your own domain?
GoodSender: the blog on your own domain is free, and so are up to 100,000 emails a month to your subscribers. Substack is also free to send, with a one time $50 for a custom domain.
Which blog platform works best with Claude?
Of these four, only GoodSender has an official MCP server that lets Claude help with the whole post, from draft to publish. beehiiv's official MCP creates drafts that you publish in beehiiv.
Start your blog on GoodSender if these trade-offs work for you.
Mentions
A stickler for good typography, I was glad to see this tiny pull request merged into Ghost. Details, details 🕵️♂️ Ghost#32057
It looks like Ghost is moving in a direction to support this via Better Auth but that might not be anytime soon... Ghost#30368
@marclou TryGhost/Ghost I will go with Ghost
Session-Based Authentication in Node.js and TypeScript with HttpOnly Cookies and CSRF Protection Most authentication tutorials end at "the user is logged in". That is the easy part. The hard part is everything around it: where the password actually goes, where the session lives, what happens when someone steals a cookie, and why your "protected" endpoint still accepts requests from a random website. A common way this goes wrong: the session token sits in localStorage, an XSS bug or a compromised dependency injects a script, and every token in every open tab can be sent to an attacker's server with one fetch. HttpOnly cookies exist to stop exactly that. This tutorial builds an authentication API that takes the boring parts seriously: Argon2id password hashing, server-side sessions, an HttpOnly session cookie, CSRF protection that matches the architecture, and a small test suite that covers the main failure paths. What you will build A small JSON API with four endpoints: | Method | Path | Purpose | | ------ | ------------------- | ---------------------------------------- | | POST | /api/auth/register| Create a user and start a session | | POST | /api/auth/login | Verify credentials and rotate the session| | GET | /api/auth/me | Return the signed-in user | | POST | /api/auth/logout | Destroy the session | Plus GET /api/csrf-token for CSRF tokens and GET /api/health. The stack: - Node.js 22 (LTS) with TypeScript. Tested on Node v22.23.2. Node 22 is in Maintenance LTS, with end-of-life on 2027-04-30. - Express 5 - Argon2id for password hashing - SQLite (via better-sqlite3) for users and sessions - express-session with a custom SQLite store - csrf-sync for the synchronizer token pattern - Zod for input validation - Vitest and supertest for tests The versions below were verified against the npm registry and the local runtime while writing: express@5.3.0, express-session@1.19.0, argon2@0.45.1, better-sqlite3@13.0.3, zod@4.6.7, helmet@8.3.0, csrf-sync@4.2.1, supertest@7.3.1, vitest@5.0.4, typescript@7.0.2, on node@v22.23.2. The architecture, and why Browser Server | | |-- POST /api/auth/login -------->| verify Argon2id hash |<-- Set-Cookie: sid=... ---------| create session row in SQLite | (HttpOnly; SameSite=Lax) | session data stays server-side | | |-- GET /api/auth/me -------------| look up session by signed id | Cookie: sid=... | load userId from the store |<-- 200 {user} ----------------- | Three decisions matter here. The session lives on the server. The cookie carries only a signed, random session ID. The user's identity is stored in a database row, not in the browser. Logging out actually revokes access, because the server can delete the row. A stateless JWT can't be revoked without extra machinery. The cookie is HttpOnly. document.cookie can't read it, so an XSS bug can't exfiltrate the session. MDN is explicit that HttpOnly doesn't stop the browser from sending the cookie, which is exactly why the next point matters. Cookies are sent automatically, so the API needs CSRF protection. If the browser attaches your session cookie to a request from evil.example, then "the request carries a valid session" is not proof the user intended it. A CSRF token that a cross-site attacker can't read fixes that. The threat model, briefly | Threat | Mitigation in this tutorial | | --- | --- | | Database leak exposes passwords | Argon2id + unique salt per password | | XSS steals the session | HttpOnly cookie; session data server-side | | Session hijacking in transit | Secure cookie in production, HTTPS | | Cross-site request forgery | Synchronizer token in a custom header | | Session fixation | Regenerate the session ID on login | | User enumeration via login timing | Verify against a dummy hash when the email is unknown | | User enumeration via registration | Not solved here - a duplicate email still returns 409; see the limitations section | | Brute force | Not solved here - see the limitations section | 1. Project setup mkdir secure-auth-demo && cd secure-auth-demo npm init -y npm pkg set type=module npm install express express-session argon2 better-sqlite3 zod helmet csrf-sync npm install -D typescript tsx vitest supertest @types/express @types/express-session \ @types/better-sqlite3 @types/supertest @types/node@^22 Two dependency notes worth recording: - csurf is deprecated. Express's own blog announced the retirement of legacy packages including csurf and points developers at a strategy that fits their architecture. For session-based apps, csrf-sync implements the OWASP synchronizer token pattern directly. Don't copy older tutorials that still install csurf. - sqlite3 is no longer maintained. Its upstream repository, TryGhost/node-sqlite3, was archived on 1 July 2026 and is read-only, so no further releases are coming. (The final npm release, 6.0.1, is not itself flagged deprecated; the archival is the reason to avoid it.) better-sqlite3 is the actively maintained alternative, and its synchronous API also removes a layer of callback plumbing. package.json scripts: { "type": "module", "engines": { "node": ">=22" }, "scripts": { "dev": "tsx watch --env-file=.env src/server.ts", "build": "tsc -p tsconfig.build.json", "start": "node --env-file-if-exists=.env dist/server.js", "typecheck": "tsc --noEmit", "test": "vitest run" } } --env-file is built into Node, so you don't need dotenv. The start script uses --env-file-if-exists: without a .env file, the config validator reports the missing SESSION_SECRET (tested on Node v22.23.2 only). With the plain --env-file flag, I ran npm start without a .env file and it failed with node: .env: not found. One caveat I hit while testing: Node's --env-file doesn't override variables that are already set in the environment. The file is a default source, not an override. If your shell exports PORT, that wins. TypeScript configuration TypeScript's Node target mapping lists Node 22 with target/lib ES2023 and module: "nodenext". With ESM and NodeNext, relative imports must include the emitted .js extension. tsconfig.json (used for type-checking everything, including tests): { "compilerOptions": { "target": "ES2023", "lib": ["ES2023"], "module": "NodeNext", "moduleResolution": "NodeNext", "types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, "noImplicitOverride": true, "noFallthroughCasesInSwitch": true, "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "noEmit": true }, "include": ["src", "tests", "vitest.config.ts"] } tsconfig.build.json narrows the build to src so tests are not shipped: { "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, "rootDir": "src", "outDir": "dist", "sourceMap": true }, "include": ["src"] } 2. Environment configuration src/config.ts validates the environment once at boot: import { z } from "zod"; /** * The whole app reads configuration from here, and the process refuses to * start with an invalid configuration instead of failing later at runtime. */ /** * A blank env var (for example `PORT=` in a .env file) should fall back to the * default rather than being coerced into 0 or NaN. */ function optionalNumber(fallback: number) { return z.preprocess( (value) => (typeof value === "string" && value.trim() === "" ? undefined : value), z.coerce.number().int().positive().default(fallback), ); } const EnvSchema = z.object({ NODE_ENV: z.enum(["development", "test", "production"]).default("development"), PORT: optionalNumber(3000), // The secret used to sign the session cookie. 32+ bytes of randomness. SESSION_SECRET: z .string() .min(32, "SESSION_SECRET must be at least 32 characters of randomness"), DATABASE_PATH: z.string().min(1).default("./data/app.db"), // How long a session stays valid, in milliseconds. Default: 8 hours. SESSION_TTL_MS: optionalNumber(1000 * 60 * 60 * 8), }); const parsed = EnvSchema.safeParse(process.env); if (!parsed.success) { const details = parsed.error.issues .map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`) .join("\n"); throw new Error(`Invalid environment configuration:\n${details}`); } export const config = parsed.data; export const isProduction = config.NODE_ENV === "production"; export const isTest = config.NODE_ENV === "test"; .env.example: NODE_ENV=development PORT=3000 # Generate with: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" SESSION_SECRET= DATABASE_PATH=./data/app.db SESSION_TTL_MS=28800000 Generate a real secret instead of typing one: node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" The min(32) check in the config is only a length check. It can't tell a random string from a guessable one. express-session signs the session ID with HMAC-256, and its README says the secret should contain at least 32 bytes of entropy, which is what the generated value gives you. An earlier version of .env.example had a placeholder here. I copied it as-is and the app booted with it, because the 45-character placeholder passed the 32-character check. That's why the value is blank now: the app refuses to start until you generate a real secret. Add .env, data/, and dist/ to .gitignore. 3. Database and users src/db.ts opens SQLite and creates the schema: import { mkdirSync } from "node:fs"; import { dirname } from "node:path"; import Database from "better-sqlite3"; import { config } from "./config.js"; // ":memory:" is used by the test suite; a file path is used everywhere else. if (config.DATABASE_PATH !== ":memory:") { mkdirSync(dirname(config.DATABASE_PATH), { recursive: true }); } export const db = new Database(config.DATABASE_PATH); db.pragma("journal_mode = WAL"); db.pragma("foreign_keys = ON"); export function migrate(): void { db.exec(` CREATE TABLE IF NOT EXISTS users ( id INTEGER PRIMARY KEY AUTOINCREMENT, email TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS sessions ( sid TEXT PRIMARY KEY, data TEXT NOT NULL, expires_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions (expires_at); `); } migrate(); src/users.ts is the data access layer. Emails are normalized before they reach it, so a plain UNIQUE column is enough: import { db } from "./db.js"; export interface User { id: number; email: string; passwordHash: string; createdAt: number; } interface UserRow { id: number; email: string; password_hash: string; created_at: number; } function toUser(row: UserRow): User { return { id: row.id, email: row.email, passwordHash: row.password_hash, createdAt: row.created_at, }; } export function findUserByEmail(email: string): User | undefined { const row = db .prepare("SELECT id, email, password_hash, created_at FROM users WHERE email = ?") .get(email) as UserRow | undefined; return row ? toUser(row) : undefined; } export function findUserById(id: number): User | undefined { const row = db .prepare("SELECT id, email, password_hash, created_at FROM users WHERE id = ?") .get(id) as UserRow | undefined; return row ? toUser(row) : undefined; } export function insertUser(email: string, passwordHash: string): User { const row = db .prepare( `INSERT INTO users (email, password_hash, created_at) VALUES (?, ?, ?) RETURNING id, email, password_hash, created_at`, ) .get(email, passwordHash, Date.now()) as UserRow; return toUser(row); } Every query uses bound parameters (?), so a crafted email can't inject SQL. 4. Password hashing OWASP's Password Storage Cheat Sheet recommends Argon2id first, and lists one of its minimum configurations: 19 MiB of memory, 2 iterations, parallelism 1 (m=19456, t=2, p=1). import argon2 from "argon2"; /** * One of the minimum Argon2id configurations listed in the OWASP Password Storage Cheat Sheet: m=19456 (19 MiB), t=2, p=1. */ const ARGON2_OPTIONS = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1, } as const; export function hashPassword(password: string): Promise<string> { return argon2.hash(password, ARGON2_OPTIONS); } export async function verifyPassword(hash: string, password: string): Promise<boolean> { try { // The encoded hash carries its own algorithm and parameters, so no options // are needed here. return await argon2.verify(hash, password); } catch { return false; } } argon2.verify takes no algorithm option, because the encoded hash string already records the algorithm, memory cost, iterations, and salt, which is why a single password_hash column is enough. verifyPassword returns false on a malformed hash rather than throwing, so a corrupted row can't produce a 500. Why not bcrypt? OWASP lists bcrypt as an acceptable choice for legacy systems, with a work factor of at least 10, and calls out its 72-byte input limit: passwords longer than that are silently truncated in some implementations. Argon2id is the better default for new code. 5. A SQLite-backed session store express-session's default MemoryStore is explicitly not for production: it leaks memory and can't be shared across processes. Since SQLite is already in the project, a small store is the cheapest correct option. express-session calls touch only if the store has one. This setup depends on it, because rolling: true uses it to push the expiry forward. A maintained package, better-sqlite3-session-store, is listed in the express-session README. I wrote a small one here so you can see what a store has to do. import session from "express-session"; import type { SessionData } from "express-session"; import { config } from "./config.js"; import { db } from "./db.js"; interface SessionRow { data: string; expires_at: number; } export class SqliteSessionStore extends session.Store { override get(sid: string, callback: (err: unknown, session?: SessionData | null) => void): void { try { const row = db .prepare("SELECT data, expires_at FROM sessions WHERE sid = ?") .get(sid) as SessionRow | undefined; if (!row) { callback(null, null); return; } if (row.expires_at <= Date.now()) { // Expired sessions are deleted lazily, on the next read. this.destroy(sid, () => callback(null, null)); return; } callback(null, JSON.parse(row.data) as SessionData); } catch (error) { callback(error); } } override set(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void { try { const expiresAt = this.expiresAtFor(sessionData); db.prepare( `INSERT INTO sessions (sid, data, expires_at) VALUES (?, ?, ?) ON CONFLICT (sid) DO UPDATE SET data = excluded.data, expires_at = excluded.expires_at`, ).run(sid, JSON.stringify(sessionData), expiresAt); callback?.(); } catch (error) { callback?.(error); } } override destroy(sid: string, callback?: (err?: unknown) => void): void { try { db.prepare("DELETE FROM sessions WHERE sid = ?").run(sid); callback?.(); } catch (error) { callback?.(error); } } override touch(sid: string, sessionData: SessionData, callback?: (err?: unknown) => void): void { try { db.prepare("UPDATE sessions SET expires_at = ? WHERE sid = ?").run( this.expiresAtFor(sessionData), sid, ); callback?.(); } catch (error) { callback?.(error); } } /** Removes every expired row. Useful as a periodic cleanup job. */ pruneExpired(): number { return db.prepare("DELETE FROM sessions WHERE expires_at <= ?").run(Date.now()).changes; } private expiresAtFor(sessionData: SessionData): number { const { expires, maxAge } = sessionData.cookie ?? {}; if (expires) { return new Date(expires).getTime(); } if (typeof maxAge === "number") { return Date.now() + maxAge; } return Date.now() + config.SESSION_TTL_MS; } } Expiry is enforced in two places: the store refuses to return an expired row, and pruneExpired() sweeps the table. Expired sessions are deleted on read, so a stale cookie is treated as signed out. 6. Session middleware and secure cookies import type { Request } from "express"; import session from "express-session"; import { config, isProduction } from "./config.js"; import { SqliteSessionStore } from "./session-store.js"; /** * OWASP recommends the `__Host-` prefix for session cookies: it forces the * browser to require Secure + Path=/ and to reject any Domain attribute, which * makes cookie tossing harder. Browsers refuse `__Host-` cookies over plain * HTTP, so we only use it in production where TLS is mandatory. */ export const SESSION_COOKIE_NAME = isProduction ? "__Host-sid" : "sid"; export const sessionStore = new SqliteSessionStore(); type RequestHandler = ReturnType<typeof session>; export function createSessionMiddleware(): RequestHandler { return session({ name: SESSION_COOKIE_NAME, secret: config.SESSION_SECRET, store: sessionStore, // Do not re-save untouched sessions. Fetching a CSRF token modifies the // session, so anonymous visitors who call /api/csrf-token do get a session row. resave: false, saveUninitialized: false, // Slide the expiry forward on activity, so active users are not logged out // mid-session while idle sessions still expire. rolling: true, cookie: { httpOnly: true, // not readable from JavaScript secure: isProduction, // HTTPS only in production sameSite: "lax", // blocks cross-site POSTs; see the CSRF section path: "/", maxAge: config.SESSION_TTL_MS, }, }); } export function regenerateSession(req: Request): Promise<void> { return new Promise((resolve, reject) => { req.session.regenerate((err) => (err ? reject(err) : resolve())); }); } export function saveSession(req: Request): Promise<void> { return new Promise((resolve, reject) => { req.session.save((err) => (err ? reject(err) : resolve())); }); } export function destroySession(req: Request): Promise<void> { return new Promise((resolve, reject) => { req.session.destroy((err) => (err ? reject(err) : resolve())); }); } Every cookie attribute here matters: - httpOnly: true. The browser won't expose this cookie to JavaScript. MDN defines it exactly that way. This is the single most important attribute for session cookies. - secure: isProduction. The cookie is only sent over HTTPS (browsers make an exception for localhost). - sameSite: "lax". The cookie isn't attached to cross-site fetch calls or cross-site POSTs, which removes the simplest CSRF vectors. MDN documents Lax as allowing same-site requests plus cross-site top-level navigations using safe methods. Strict is stronger but hurts usability: clicking a link into your app from another site would arrive logged out. - No domain attribute. Omitting it makes this a host-only cookie. OWASP recommends this, and it is required for the __Host- prefix. - maxAge. A bounded lifetime instead of a session cookie that lives until the browser closes. The three helper functions wrap the callback-based express-session API in promises so the route handlers stay readable. They are also why the tests can assert on await. Session fixation An attacker who can force a victim to use a session ID the attacker already knows can take over the session. Regenerating the ID on every privilege change (registration, login, and role change) makes that useless. regenerateSession(req) does that, and it is called on both login and registration. One consequence you must handle in the client: the CSRF token lives in the session, so it is discarded by regeneration. After logging in, fetch /api/csrf-token again. This surprises people; it's not a bug. 7. CSRF protection OWASP recommends the synchronizer token pattern for stateful apps: the server generates a secret token tied to the session and validates a submitted copy on unsafe methods. csrf-sync implements exactly that, storing the token in req.session.csrfToken. import { csrfSync } from "csrf-sync"; /** * csrf-sync implements the OWASP "synchronizer token pattern" for stateful * (session-based) apps. * * Defaults we rely on, and why: * - The token is stored server-side in `req.session.csrfToken`. * - Unsafe methods (everything except GET/HEAD/OPTIONS) must present the token * in the `x-csrf-token` request header. * - Invalid or missing tokens produce an error with code `EBADCSRFTOKEN`, * which the error handler turns into a 403. */ export const { csrfSynchronisedProtection, generateToken } = csrfSync(); I checked the installed source while writing this: csrf-sync ignores GET, HEAD, and OPTIONS by default, reads the token from the x-csrf-token header, generates 128 random bytes (a 256-character hex token), and raises an error with code: "EBADCSRFTOKEN". The token must not live in a cookie, and it must not be sent in a URL, because either would defeat the point. A custom header works because reading a custom header requires CORS permission the attacker doesn't have. 8. Routes import { Router } from "express"; import { z } from "zod"; import { isProduction } from "../config.js"; import { hashPassword, verifyPassword } from "../password.js"; import { requireAuth, currentUserId } from "../middleware/require-auth.js"; import { SESSION_COOKIE_NAME, destroySession, regenerateSession, saveSession, } from "../session.js"; import { findUserByEmail, findUserById, insertUser } from "../users.js"; export const authRouter = Router(); /** Normalise the email before validating it, so case never creates duplicates. */ const emailSchema = z.string().trim().toLowerCase().pipe(z.email()); const registerSchema = z.object({ email: emailSchema, // Length matters more than character-class rules. NIST SP 800-63B recommends // allowing long passphrases; we require at least 12 characters and cap the // length so a huge body cannot be used to burn CPU. password: z.string().min(12).max(200), }); // Login must not re-apply the registration policy: doing so would leak the // policy and lock out accounts created before a policy change. const loginSchema = z.object({ email: emailSchema, password: z.string().min(1).max(200), }); interface PublicUser { id: number; email: string; } function toPublicUser(user: { id: number; email: string }): PublicUser { return { id: user.id, email: user.email }; } function isUniqueViolation(error: unknown): boolean { return ( typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "SQLITE_CONSTRAINT_UNIQUE" ); } /** * A throwaway hash used when the submitted email does not exist. A failed login * then performs one real Argon2id verification whether or not the account * exists, which narrows the timing gap. It does not remove it: the database * lookup and session handling still differ, so treat this as best-effort. */ let dummyHash: Promise<string> | undefined; function getDummyHash(): Promise<string> { dummyHash ??= hashPassword("timing-equalisation-placeholder-value"); return dummyHash; } void getDummyHash(); // warm up at startup so the first unknown-email login isn't slower authRouter.post("/register", async (req, res) => { const parsed = registerSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: "Invalid registration details", issues: parsed.error.issues.map((issue) => ({ field: issue.path.join("."), message: issue.message, })), }); return; } const { email, password } = parsed.data; if (findUserByEmail(email)) { res.status(409).json({ error: "Email is already registered" }); return; } const passwordHash = await hashPassword(password); let user; try { user = insertUser(email, passwordHash); } catch (error) { // Two requests can pass the check above at the same time; the UNIQUE // constraint is the real guarantee, so translate it into the same 409. if (isUniqueViolation(error)) { res.status(409).json({ error: "Email is already registered" }); return; } throw error; } // Never keep the pre-login session id after a privilege change. await regenerateSession(req); req.session.userId = user.id; await saveSession(req); res.status(201).json({ user: toPublicUser(user) }); }); authRouter.post("/login", async (req, res) => { const parsed = loginSchema.safeParse(req.body); if (!parsed.success) { res.status(400).json({ error: "Invalid login payload" }); return; } const { email, password } = parsed.data; const user = findUserByEmail(email); const hash = user?.passwordHash ?? (await getDummyHash()); const passwordMatches = await verifyPassword(hash, password); if (!user || !passwordMatches) { // Identical response for "no such user" and "wrong password" so the API // does not confirm which emails exist. res.status(401).json({ error: "Invalid email or password" }); return; } await regenerateSession(req); req.session.userId = user.id; await saveSession(req); res.json({ user: toPublicUser(user) }); }); authRouter.get("/me", requireAuth, (req, res) => { const userId = currentUserId(req); const user = userId === undefined ? undefined : findUserById(userId); if (!user) { res.status(401).json({ error: "Authentication required" }); return; } res.json({ user: toPublicUser(user) }); }); authRouter.post("/logout", async (req, res) => { await destroySession(req); res.clearCookie(SESSION_COOKIE_NAME, { httpOnly: true, secure: isProduction, sameSite: "lax", path: "/", }); res.status(204).end(); }); A few notes on that code: - toPublicUser never includes passwordHash. Don't serialize database rows straight to JSON. An explicit projection is the difference between a working API and a hash-leak. - Normalize, then validate. z.string().trim().toLowerCase().pipe(z.email()) means " Alice@Example.COM " and "alice@example.com" are the same account. - The dummy-hash trick. When the email is unknown, the code still runs a real Argon2id verification against a throwaway hash, so a failed login does the same expensive work whether or not the account exists. Without it, a request naming a non-existent email would skip hashing entirely and return noticeably faster, a timing side channel that can disclose which emails are registered. This narrows the gap; it doesn't make the two paths identical, because the database lookup and session handling still differ. I saw the lazy version of this: my first unknown-email login after server start took 0.0457 s against about 0.024 s afterwards, because the dummy hash was created lazily. - One error message for both failures. "Invalid email or password" for both cases avoids a second enumeration channel. - The database constraint is the real guard. The pre-check is for a friendly 409; the UNIQUE constraint is what actually prevents two accounts in a race. Both paths return the same response. SP 800-63B-4 sets 15 characters as the minimum for single-factor passwords; this demo uses 12 to balance security with user friction. src/middleware/require-auth.ts: import type { NextFunction, Request, Response } from "express"; /** The id of the authenticated user, or `undefined` if not signed in. */ export function currentUserId(req: Request): number | undefined { const { userId } = req.session; return typeof userId === "number" ? userId : undefined; } export function requireAuth(req: Request, res: Response, next: NextFunction): void { if (currentUserId(req) === undefined) { res.status(401).json({ error: "Authentication required" }); return; } next(); } And the session type augmentation, src/types/express-session.d.ts: import "express-session"; declare module "express-session" { interface SessionData { /** The authenticated user's id. Absent for anonymous visitors. */ userId?: number; } } 9. App wiring: order is the specification import express, { type Express } from "express"; import helmet from "helmet"; import { isProduction } from "./config.js"; import { csrfSynchronisedProtection, generateToken } from "./middleware/csrf.js"; import { errorHandler, notFoundHandler } from "./middleware/error-handler.js"; import { authRouter } from "./routes/auth.js"; import { createSessionMiddleware } from "./session.js"; export function createApp(): Express { const app = express(); // Behind a TLS-terminating proxy, trust the first hop so express-session sees // X-Forwarded-Proto and will set `Secure` cookies correctly. if (isProduction) { app.set("trust proxy", 1); } app.disable("x-powered-by"); app.use(helmet()); app.use(express.json({ limit: "16kb" })); app.use(createSessionMiddleware()); app.get("/api/health", (_req, res) => { res.json({ status: "ok" }); }); // Hand the client a CSRF token bound to its session. GET is a safe method, so // this route is exempt from CSRF checks. app.get("/api/csrf-token", (req, res) => { res.json({ csrfToken: generateToken(req) }); }); app.use(csrfSynchronisedProtection); app.use("/api/auth", authRouter); app.use(notFoundHandler); app.use(errorHandler); return app; } Order matters: 1. helmet() sets security headers on every response, including errors, so it goes first. 2. express.json({ limit: "16kb" }) must precede anything reading req.body, and the size limit stops a giant body from burning CPU in Argon2. Requests over the limit are rejected with 413 before any handler runs. 3. The session middleware must precede CSRF and routes, because both need req.session. 4. /api/csrf-token must be registered before csrfSynchronisedProtection. Register it after, and clients can never obtain a token for their first mutation. It's an easy mistake to make. 5. CSRF protection guards every unsafe request below it. 6. notFoundHandler and errorHandler come last. A note if you are new to Express 5: it automatically forwards rejected promises from async handlers to the error middleware, so the route handlers above don't need try/catch around every await. That was a manual next(err) in Express 4. src/middleware/error-handler.ts: import type { NextFunction, Request, Response } from "express"; interface HttpErrorLike { code?: string; type?: string; status?: number; statusCode?: number; } export function notFoundHandler(_req: Request, res: Response): void { res.status(404).json({ error: "Not found" }); } /** * Central error handler. It must keep all four parameters — Express detects * error-handling middleware by arity. */ export function errorHandler( err: unknown, _req: Request, res: Response, next: NextFunction, ): void { if (res.headersSent) { next(err); return; } const httpError = err as HttpErrorLike; if (httpError?.code === "EBADCSRFTOKEN") { res.status(403).json({ error: "Invalid or missing CSRF token" }); return; } // body-parser marks malformed JSON with type: "entity.parse.failed". if (httpError?.type === "entity.parse.failed") { res.status(400).json({ error: "Malformed JSON body" }); return; } // Other body-parser failures carry their own 4xx status (for example // "entity.too.large" -> 413 from the 16kb limit, and "encoding.unsupported" // -> 415). Honour it instead of reporting a server fault for a client error. const status = httpError?.status ?? httpError?.statusCode; if (typeof status === "number" && status >= 400 && status < 500) { res.status(status).json({ error: httpError?.type === "entity.too.large" ? "Request body too large" : "Bad request", }); return; } // Anything else is unexpected: log it server-side and return a generic // message so stack traces and SQL errors never reach the client. console.error("Unhandled error:", err); res.status(500).json({ error: "Internal server error" }); } The handler maps each failure to a status: EBADCSRFTOKEN to 403, a malformed JSON body to 400, and other body-parser errors to the 4xx status they already carry, for example entity.too.large to 413 when a request exceeds the 16kb limit. Without that last branch an oversized body falls through to the generic case and is reported as a 500 server fault, which misleads the client and buries a client error in your server logs. The error handler must keep all four parameters, because Express identifies error middleware by arity. Dropping the unused next silently turns it into ordinary middleware that never runs. 10. Entry point import { createApp } from "./app.js"; import { config } from "./config.js"; import { sessionStore } from "./session.js"; const app = createApp(); const server = app.listen(config.PORT, () => { console.log(`Auth API listening on http://localhost:${config.PORT} (${config.NODE_ENV})`); }); const PRUNE_INTERVAL_MS = 30 * 60 * 1000; const pruneTimer = setInterval(() => { const removed = sessionStore.pruneExpired(); if (removed > 0) { console.log(`Pruned ${removed} expired session(s)`); } }, PRUNE_INTERVAL_MS); pruneTimer.unref(); function shutdown(signal: NodeJS.Signals): void { console.log(`${signal} received, shutting down`); clearInterval(pruneTimer); server.close(() => { process.exit(0); }); } process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); 11. Tests vitest.config.ts injects a deterministic environment, including an in-memory database so each run starts clean: import { defineConfig } from "vitest/config"; export default defineConfig({ test: { env: { NODE_ENV: "test", PORT: "3000", // 32+ characters so it passes config validation. SESSION_SECRET: "test-only-secret-value-please-change-me-0001", // In-memory SQLite: each test file gets its own throwaway database. DATABASE_PATH: ":memory:", }, }, }); The integration tests drive the app through supertest with a cookie-persisting agent. The suite has 6 cases, and this is the whole file: import request from "supertest"; import { describe, expect, it } from "vitest"; import { createApp } from "../src/app.js"; import { db } from "../src/db.js"; const app = createApp(); const VALID_PASSWORD = "correct horse battery staple"; type Agent = ReturnType<typeof request.agent>; function setCookieHeaders(res: request.Response): string[] { const raw = res.headers["set-cookie"]; if (!raw) return []; return Array.isArray(raw) ? raw : [raw]; } function sessionCookie(res: request.Response): string { const cookie = setCookieHeaders(res).find((value) => value.startsWith("sid=")); if (!cookie) throw new Error("Response did not set a session cookie"); return cookie; } async function getCsrfToken(agent: Agent): Promise<string> { const res = await agent.get("/api/csrf-token"); expect(res.status).toBe(200); return res.body.csrfToken as string; } async function register(agent: Agent, email: string, password = VALID_PASSWORD) { const token = await getCsrfToken(agent); return agent.post("/api/auth/register").set("x-csrf-token", token).send({ email, password }); } describe("auth api", () => { it("sets an HttpOnly, SameSite=Lax session cookie", async () => { const agent = request.agent(app); const res = await register(agent, "cookie@example.com"); const cookie = sessionCookie(res); expect(cookie).toMatch(/HttpOnly/i); expect(cookie).toMatch(/SameSite=Lax/i); expect(cookie).not.toMatch(/;\s*Secure/i); // dev/test, so it also works over HTTP }); it("rejects a duplicate email with 409", async () => { const first = request.agent(app); expect((await register(first, "dup@example.com")).status).toBe(201); const second = request.agent(app); const res = await register(second, "dup@example.com"); expect(res.status).toBe(409); }); it("rotates the session id on login to prevent session fixation", async () => { const agent = request.agent(app); const registered = await register(agent, "rotate@example.com"); const before = sessionCookie(registered); const token = await getCsrfToken(agent); const loggedIn = await agent .post("/api/auth/login") .set("x-csrf-token", token) .send({ email: "rotate@example.com", password: VALID_PASSWORD }); expect(loggedIn.status).toBe(200); expect(sessionCookie(loggedIn)).not.toBe(before); }); it("rejects an unknown email with the same message as a wrong password", async () => { const agent = request.agent(app); const token = await getCsrfToken(agent); const res = await agent .post("/api/auth/login") .set("x-csrf-token", token) .send({ email: "nobody@example.com", password: VALID_PASSWORD }); expect(res.status).toBe(401); expect(res.body.error).toBe("Invalid email or password"); }); it("blocks a state-changing request that has no token", async () => { const res = await request(app) .post("/api/auth/register") .send({ email: "no-csrf@example.com", password: VALID_PASSWORD }); expect(res.status).toBe(403); expect(res.body.error).toMatch(/CSRF/i); }); it("invalidates the session on logout", async () => { const agent = request.agent(app); await register(agent, "logout@example.com"); expect((await agent.get("/api/auth/me")).status).toBe(200); const token = await getCsrfToken(agent); expect((await agent.post("/api/auth/logout").set("x-csrf-token", token)).status).toBe(204); expect((await agent.get("/api/auth/me")).status).toBe(401); }); }); My first version of the fourth test failed with TypeError: Cannot read properties of null (reading 'address') from supertest, because I awaited the CSRF token inside the request chain. Fetching the token into a variable first, as the test above does, fixes it. Run it: npm test Expected result on this codebase: Test Files 1 passed (1) Tests 6 passed (6) Duration 808ms (import 45%, tests 43%, transform 11%, worker 1%) Durations differ between machines; the counts are what matter. Also run npm run typecheck (which must exit 0) and npm run build before deploying anywhere; build compiles src to dist/ via tsconfig.build.json. 12. Trying it by hand With npm run dev running, this curl sequence exercises the whole flow. The cookie jar is what makes the session persist across calls. JAR=$(mktemp); B=http://localhost:3000 # 1. Grab a CSRF token; the cookie jar now holds the session cookie. TOKEN=$(curl -s -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/') # 2. Register (201) curl -s -b "$JAR" -c "$JAR" -X POST $B/api/auth/register \ -H 'Content-Type: application/json' -H "x-csrf-token: $TOKEN" \ -d '{"email":"me@example.com","password":"correct horse battery staple"}' # 3. Get a fresh token (the session was rotated) and read the profile (200) TOKEN=$(curl -s -b "$JAR" -c "$JAR" $B/api/csrf-token | sed -E 's/.*"csrfToken":"([^"]+)".*/\1/') curl -s -b "$JAR" $B/api/auth/me # 4. Logout (204), then confirm access is gone (401) curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" -c "$JAR" -X POST $B/api/auth/logout -H "x-csrf-token: $TOKEN" curl -s -o /dev/null -w '%{http_code}\n' -b "$JAR" $B/api/auth/me Output from this project, including the Set-Cookie header (session ID shortened): Set-Cookie: sid=s%3A<id>.<signature>; Path=/; Expires=Wed, 14 Oct 2026 19:53:24 GMT; HttpOnly; SameSite=Lax Note there is no Secure flag because NODE_ENV=development. Set NODE_ENV=production, terminate TLS, and the cookie becomes __Host-sid=...; Secure; HttpOnly; SameSite=Lax. Common mistakes and troubleshooting "403 on every POST, even with a token." Three usual causes. The token endpoint is registered after csrfSynchronisedProtection. Or the client is reading csrfToken from the wrong place. Or the client is sending the token as a cookie or a query parameter instead of the x-csrf-token header. "Login succeeds but the very next mutation fails with 403." That's expected. Login regenerates the session, which discards the CSRF token. Fetch a new token after authenticating. "The cookie is set but never sent back." Check, in order: secure: true over plain HTTP (the browser drops it, with a localhost exception); sameSite: "strict" breaking a top-level navigation from another origin; a domain mismatch; or a cross-origin client that is not sending credentials. For fetch calls, credentials: "include" is required, along with explicit CORS Access-Control-Allow-Credentials: true and a specific origin, because * is not allowed with credentials. "req.session is undefined in a route." The session middleware is registered after the route, or after CSRF. Both need it, so it must come first. "Users get logged out randomly." resave and saveUninitialized are usually to blame, or the store is not saving. With saveUninitialized: false, a session is only created when it is actually modified, which is correct for login flows. "Argon2 install fails." argon2 and better-sqlite3 are native modules. Most platforms get prebuilt binaries; if not, the resolution is a working build toolchain, not a switch to a pure-JS hash. "JSON.parse throws inside the session store." If you changed the stored shape, clear the sessions table. A schema change to session data effectively logs everyone out. "SESSION_SECRET must be at least 32 characters" on boot. That message comes from the config validator. Generate a value rather than inventing one. It also fires if SESSION_SECRET is left blank in your .env. What this tutorial does not cover This is not a finished product. Deliberately out of scope: - Registration discloses whether an email is registered. POST /api/auth/register answers 409 for an email that already exists, so anyone can probe the endpoint for accounts. Login is protected against this; registration is not, because the answer the caller wants is also the answer that makes the API useful. Hiding it requires moving account creation behind an email-verification step. - Rate limiting, lockout, and hashing cost. Nothing here slows down credential stuffing. Add per-IP and per-account throttling (for example express-rate-limit) and alert on repeated failures. Argon2id is expensive by design, so a burst of login attempts can exhaust worker capacity. The 16kb body limit and the 200-character password cap bound one request's input; they don't bound the request rate, which is the rate limiter's job. - Email verification, password reset, and MFA. Email verification and password reset both need a token with its own single-use, expiring lifecycle, and MFA should be added before anything else. - Anonymous session rows. Every GET /api/csrf-token without a cookie creates a row, and nothing limits how many. Three cookie-less calls added three rows to my sessions table. My first draft's comment claimed anonymous visitors don't get a session. - No absolute session lifetime. rolling: true slides the expiry forward on every authenticated request, so an active session never expires on its own. - No breached-password check. SP 800-63B-4 says passwords SHALL be compared against a blocklist; this code doesn't. - Secret rotation, audit logging, and observability. OWASP's session guidance covers renewing session IDs and detecting session-related attacks; this code does neither. There are no metrics, no tracing, and no structured logging. - Scaling. SQLite is a single-writer database. It is a fine fit for one process or one machine, and it is the wrong fit for horizontal scaling. The store interface is the seam: swap in Redis or Postgres. - __Host- prefix in development. It requires HTTPS, which is why it is production-only here. If you add local TLS, use it everywhere. One more thing: disabling CSRF protection because "it is only an API" is not a mitigation. If your API is called from a browser with a cookie, it is a CSRF target. Conclusion The difference between "we added login" and "we added authentication" is a short list of decisions: hash with Argon2id at OWASP's parameters, keep session state on the server, mark the cookie HttpOnly and Secure, add SameSite, protect unsafe methods with a synchronizer token, regenerate the session ID on login, and make login fail the same way for an unknown email and a wrong password so it doesn't leak which accounts exist (registration still does, see the limitations). The complete, runnable project, including the SQLite session store and all 6 tests, is at secure-auth-demo. npm install && npm test should print 6 passed. References - OWASP Password Storage Cheat Sheet - Argon2id parameters, bcrypt caveats, scrypt and PBKDF2 guidance - OWASP Session Management Cheat Sheet - session ID entropy, cookie attributes, __Host- prefix, session renewal and destruction - OWASP Cross-Site Request Forgery Prevention Cheat Sheet - synchronizer token pattern, token transmission, SameSite as defense in depth - MDN: Set-Cookie header - exact semantics of HttpOnly, Secure, and SameSite - MDN: Using HTTP cookies - cookie scope, lifetime, and security - Node.js CLI documentation (--env-file) - env file loading and precedence - Node.js releases - which Node versions are LTS - TypeScript Node target mapping - target, lib, and module for Node 22 - TypeScript module option - NodeNext semantics - Express 5 migration guide - promise rejection handling for async handlers - Express: retiring legacy packages - why csurf is deprecated - express-session on npm - MemoryStore is not production-ready - csrf-sync on npm - synchronizer token pattern for sessions - argon2 on npm - hashing and verification API - better-sqlite3 on npm - synchronous SQLite for Node - NIST SP 800-63B-4 - password length and memorized-secret guidance
Why is Wix better than WordPress? Wix is better than WordPress at one job: giving a non-technical owner a working, hosted site that nobody has to patch. It is worse at the jobs WordPress was built for, which are owning your content, moving it somewhere else, and bolting on almost any feature through plugins or code. So the honest answer to the title is "better for some people, in some situations," and the situation is usually decided by two questions: who will maintain the site, and will you ever need to leave? If the answer is "nobody technical" and "probably not," Wix is a sensible pick. If it's "me or a developer" and "maybe," WordPress usually fits better. Disclosure: I work on VeloCMS, which appears near the end as one of five alternatives. It gets about the same space as the others, its weak points are listed, and there's a section on when to skip it. Last verified: 2026-10-11. How I checked: every price, limit and feature below was read from the vendor's own page on 2026-10-11, with the link next to the claim, and prices are in USD as a US visitor sees them. Where a page wouldn't give me a number, I say so. Vendor marketing claims (uptime, "AI-powered," and so on) are labelled as claims, and my opinions are labelled as mine. Is Wix actually better than WordPress? Not in general. W3Techs, as of 2026-10-11, shows WordPress on 40.1% of all websites it tracks, which is 58.6% of sites with a known content management system. On that same known-CMS basis, Shopify has 7.8%, Wix 6.1%, Squarespace 3.6% and Webflow 1.2% (Wix is on 4.2% of all websites) (W3Techs). Popularity doesn't make WordPress better for you, but it does mean more tutorials, more freelancers and more ready-made answers. "Better" only means something once you attach it to a task. People asking this are usually weighing upkeep, cost, flexibility, design freedom, ownership and speed to launch. Wix wins upkeep and launch speed for most beginners. WordPress wins ownership and flexibility. Cost and design swing either way. Where does Wix genuinely beat WordPress? Four places, and they're real. Nobody has to patch anything Wix says it applies security patches and updates automatically and runs DDoS protection and 24/7 security monitoring on every hosted site (Wix blog on hosting). That's the vendor describing itself, so read it as a claim, but the structural point holds anyway: a Wix owner has no plugin list to keep current. That matters because the WordPress risk is concentrated in exactly that list. Patchstack counted 11,334 new vulnerabilities in the WordPress ecosystem in 2025. Of those, 91% were in plugins, 9% in themes and only 6 in WordPress core, all low priority. It also found 46% were not fixed by the time they were publicly disclosed, and that for heavily exploited flaws the median time to exploitation was about 5 hours (Patchstack, State of WordPress Security in 2026). The core software is fine. The mess is what you install around it. Fairness check: WordPress does auto-update itself. Per the official upgrade docs, new installs since WordPress 5.6 get automatic minor and major core updates by default, but plugins and themes update automatically only in special cases chosen by the WordPress security team (WordPress developer docs). The rest is your job or your host's. Hosting, SSL and support come in the box A Wix premium plan bundles hosting, a certificate and help. Wix's own plan write-up says every premium tier includes multi-cloud hosting, built-in security infrastructure and 24/7 customer care (Wix premium plans), and its hosting article claims free HTTPS with an automatic SSL certificate, 99.99% uptime and a CDN with 200+ nodes (again, Wix's numbers, not an audit). When something breaks there is one company to call. On WordPress you assemble that. The software is free, you choose a host, and WordPress.org recommends three: Pressable, Bluehost and Hostinger (WordPress.org hosting). Hostinger's WordPress page showed me Premium at $2.99 a month on a 48-month commitment, renewing at $10.99 (Hostinger). The editor and the AI helper are built together Wix's newer editor, Harmony, launched in January 2026 and mixes drag-and-drop with an AI agent called Aria, according to Wix's help center (About Wix Harmony). For someone who has never built a site, "describe it and then nudge it by hand" is a faster start than choosing a WordPress theme, installing a page builder and learning its panels. I'd call that an actual advantage. It comes with caveats, all from that same help page: rollout is gradual, it's English only for now, CMS features are on a list of things Wix is "working on bringing," and custom code (JavaScript or backend) isn't available in Harmony. So the easy new editor and the developer-friendly Velo tooling currently live in different places. One account, one bill Payments, scheduling, a blog and a store all sit behind one subscription ladder. On the Wix plans page, the Core tier is where accepting payments, basic eCommerce, and scheduling and services first appear, with Business and Business Elite adding standard and advanced eCommerce (Wix plans). You don't pick a booking plugin and then check whether it plays nicely with your checkout plugin. If a business needs those things working together by Friday, that coherence is worth money. Where does WordPress beat Wix? Can you take your site with you? This is the biggest gap, and it's the question I'd ask first. Wix's help center says a Wix site has to run on Wix's servers, because the platform's architecture doesn't support external hosting. You can attach a domain you bought elsewhere, but only on a paid plan (Wix help: exporting or embedding your site). There's no "download the site and upload it to a new host" option. Content is patchy too. Wix's help center says that, today, it isn't possible to export blog posts to other platforms, and lists the capability as a feature request (Wix help: exporting blog posts). CMS collections are the exception: Wix's help center says you can export a collection to CSV, all fields or just the filtered rows. Useful, but it's data, not a site. WordPress is the opposite. Its Tools, Export screen produces one XML (WXR) file containing posts, pages, custom post types, comments, custom fields, categories, tags, custom taxonomies and users, and another WordPress site can import it (WordPress export docs). The docs list content types and don't mention media files or theme design, so treat uploads and the look of the site as separate jobs. It isn't a magic wand. But your words come with you. The software is licensed under the GPL (version 2 or later), which is the legal basis for that freedom (WordPress.org about). How big is the extension ecosystem? WordPress.org describes its plugin directory as the largest directory of free and open source WordPress plugins and doesn't print a total on that page (WordPress plugin directory). The individual numbers are easier to see. Yoast SEO alone lists 10+ million active installations (Yoast SEO on WordPress.org), and WordPress.com says its paid plans can install 60,000+ plugins (WordPress.com pricing). Wix has an App Market, but the page I read gave no total, only sub-collection counts such as 22 business-solution apps and 35 "Made by Wix" apps (Wix App Market), so I can't quote a catalog size. Nothing on Wix compares to dropping arbitrary PHP into a site. When the app you need doesn't exist, you're in Velo territory. What happens when you need code? Wix has a real answer, called Velo. In the regular editor you flip on Dev Mode and get JavaScript on the front end plus serverless Node.js on the back end, a built-in database, npm packages and scheduled jobs, all hosted on Wix's cloud (About Velo by Wix). For plenty of "I need one custom form handler" jobs, that's enough. The ceilings show up in the smaller print. Custom code snippets require a published site with a connected domain, and Wix's help center says tracking scripts such as Google Analytics, Google Tag Manager and Facebook or TikTok pixels do not work through custom code (you use Wix's marketing integrations instead). It also notes that, as of December 2025, some snippets that used to work may now be blocked by new frontend security measures, and that snippets are deleted if you assign a different domain to the site (Wix help: embedding custom code). Compare WordPress.com's Business plan, which lists SFTP/SSH, Git deployment and backups at $25 a month on annual billing (WordPress.com pricing), or a self-hosted site where you can run what your server allows. It isn't that WordPress is easy. It's that fewer walls exist. Is Wix's SEO weaker? Less than its reputation suggests. Wix's SEO page lists an editable robots.txt, automatic structured data on product, event and blog pages with options to customize it, editable canonical tags, custom meta titles and descriptions, editable slugs with automatic 301 redirects, and bulk redirect import of up to 500 URLs from a CSV (Wix SEO features). That covers most of what a small site needs. WordPress pulls ahead when you want more than the basics, because SEO tooling is a plugin category with real competition, plus server-level control on self-hosted setups. That's my opinion about headroom, not a measured ranking, and I know of no fair head-to-head dataset that settles it. If you expect unusual schema or redirect rules, test that specific thing before committing. Does the price stay low as you grow? Wix prices climb in steps, and some of the steps are tied to what you can do. On the US version of the plans page, yearly billing paid in full at purchase and before tax, Light is $17 a month, Core $29, Business $39 and Business Elite $159 (Wix plans). Wix's own premium plans article quotes the same $17 to $159 range. Storage runs 2 GB on Light, 50 GB on Core and 100 GB on Business, and Business Elite is unlimited. Prices and currency vary by location and Wix sometimes promotes discounts, so read the number on the checkout page. The free plan is limited by design: 500 MB of storage, 1 GB of bandwidth, a Wix address, Wix ads, no custom domain and no changing the template after creation (Wix help: free site). Database-style content has a second meter. Wix lists collection item limits of 1,500 on Light, 4,000 on Core, 20,000 on Business and 10,000,000 on Business Elite and Elite (Wix help: collection limits). Then there's payment processing. Wix Payments in the US lists 2.9% plus $0.30 per standard card transaction, 3.7% plus $0.30 for American Express, 3.49% plus $0.49 for PayPal and 6% plus $0.30 for buy-now-pay-later options, plus a 1.5% cross-border fee and 1% currency conversion on top where they apply. That support page doesn't make fees depend on the plan (Wix Payments fees). I didn't find a separate Wix commission on the plan page, so the fees you pay are the processor's. Squarespace prints platform fees by plan (more below), and WordPress.com's Commerce plan lists 0% transaction fees (WordPress.com pricing). WordPress isn't free at scale either: the software costs $0, but the host renews higher than its intro price and premium themes or plugins are extra (I haven't priced those, so no number). Wix is cheaper when you want everything bundled and small; WordPress is cheaper when you can run it yourself and keep add-ons lean. How much would a small site cost over a year? Rough arithmetic from the prices above, mine, before tax, domain renewal and any paid app or plugin: - Wix Core, yearly billing: $29 × 12 = $348 as listed, payments and scheduling included - WordPress.com Business, yearly billing: $25 × 12 = $300, with plugin install, SFTP/SSH and Git - WordPress.com Premium, yearly billing: $8 × 12 = $96, with payment buttons - Self-hosted WordPress on Hostinger Premium: $10.99 × 12 = $131.88 a year once the 48-month promo ends, plus any themes and plugins you buy The WordPress rows are cheaper if you handle the technical side yourself. The Wix row costs more on paper but includes support and no maintenance, which is a cost you'd otherwise pay in time. What does the Wix versus WordPress choice look like as a checklist? Run through these in order. Each answer pushes you one way, and if three or four point to one side, that's your answer. 1. Will you ever need to move platforms? If yes, lean WordPress. If you're certain you won't, either works. 2. Do you need a plugin that exists only for WordPress (a membership tool, a niche CRM connector, a booking engine you already use)? Then WordPress. If Wix's native booking and store cover it, Wix works. 3. Who maintains it? Nobody technical: Wix or another hosted builder. You, or a developer who enjoys it: WordPress. 4. How much custom code? A snippet or two suits either. Heavy backend logic points to WordPress or a code-first tool; Velo is real but quota-bound. 5. Do you need paid memberships, a newsletter or bookings in the same login? A hosted bundle is easier. On WordPress each is usually a plugin. 6. How many content items will you store? A few thousand won't touch Wix's caps. Tens of thousands means reading the item limit for the plan you'd actually buy. 7. How sensitive are you to transaction fees? Price the processor fees for your card mix and country, then compare each platform's fee ladder. 8. Have you rehearsed your exit? On Wix, check what comes out before you build big: CMS collections can export to CSV, blog posts officially can't. On WordPress, do a test export and import on a staging site. 9. Does your agency or freelancer already know one of them? A familiar tool beats a theoretically better one. 10. Who answers at 2 a.m. when the site is down? Wix gives you one support line. WordPress gives you your host's line and your own debugging. My tiebreak: a salon or consultant with no developer who wants a site, bookings and a blog should probably start hosted. A publisher, agency or anyone treating the site as a long-term asset should start on something exportable. Which other options are worth a look? If you're comparing Wix and WordPress, you're probably also half-considering a few more. These are in no ranking order, and the prices below are the ones I read on 2026-10-11. WordPress.com versus WordPress.org These get confused constantly. wordpress.org is the free GPL software you host yourself. wordpress.com is a hosted service that runs WordPress for you. The free plan there has 1 GB, shows ads and doesn't allow plugins. Paid plans, in USD with yearly versus monthly billing, are Personal at $4 yearly or $9 monthly (6 GB), Premium at $8 or $18 (13 GB), Business at $25 or $40 (50 GB) and Commerce at $45 or $70 (50 GB). Paid plans can install plugins. It gives you the WordPress content model with a hosted safety net; the trade is working inside WordPress.com's plan rules rather than a bare server. Squarespace Squarespace is the nearest Wix rival for design-led sites. In USD it lists Basic at $16 a month billed yearly ($21 billed monthly), Core at $23 ($32), Plus at $39 ($48) and Advanced at $99 ($119), with a 14-day trial and no card required. Hosting, a free SSL certificate and 24/7 support are included. Its fee ladder is the thing to read: online-store sales cost 2% on Basic and 0% above it, and memberships and digital content cost 7%, 5%, 1% and 0% going up the plans. Leaving is limited: its export gives a WordPress-compatible XML file covering one blog page and a few page types, but not store pages, products, drafts or custom CSS. Webflow Webflow is a visual builder with design control closer to hand-written CSS. Site plans are Starter (free, a webflow.io domain, a limited CMS and 2 static pages), Basic at $15 a month billed yearly (a custom domain, 300 static pages, 10 GB bandwidth, pitched at sites that don't need a CMS) and Premium at $25 billed yearly, which adds the full Webflow CMS. Code export sits on the Workspace Core plan at $19 a month billed yearly, and Webflow's pricing page warns that dynamic content has to be exported collection by collection and that forms stop working. Site plans and workspace plans are listed separately, so the real total can be the sum of both. I'd also expect a steeper learning curve than Wix (my opinion, not a measurement). Ghost Ghost is a publishing platform rather than a site builder. Ghost(Pro) starts at $15 a month billed yearly for Starter (1,000 members, one staff user), $29 for Publisher (three staff users, custom themes, 0% transaction fees) and $199 for Business (10,000 members, 15 staff users). It's MIT-licensed on GitHub, so self-hosting is possible, though the README lists the managed service first. It's excellent for writers and newsletters. It isn't a brochure-site or bookings tool. VeloCMS VeloCMS is a hosted blog-first platform that also does newsletters, memberships and a small store. Pricing is Pro at $9 a month, Business at $29 and Agency at $69. Pro includes a custom domain, a visual page builder and a membership paywall, within 5 GB of storage and 5,000 subscribers on one site. There's no free plan: every plan starts with a 14-day trial that takes a card at signup and charges nothing until day 15. It takes no platform fee on sales, though payment-provider fees still apply. The weak points are plain. It's young, the marketplace shows "57 free integrations" that are all first-party, there are 40 themes, and the developer page says it's managed SaaS only with third-party plugin development not open yet. Content exports as JSON or CSV, and its Wix import guide reads your public site, 30 pages per run, as drafts. Who is each option best for? - Wix: owners who want a good-looking site, a blog, bookings or a small shop on one bill, edited by non-developers, and who don't expect to migrate. - WordPress.org (self-hosted): people who need a specific plugin, want full ownership of the files and database, and will keep it patched themselves or pay someone to. - WordPress.com: people who like the WordPress ecosystem and want the hosting handled, and can live with plan-based rules. - Squarespace: design-led small businesses that want a polished site and simple editor and accept a closed platform and fees on sales. - Webflow: designers who want precise layout control and structured CMS collections, and are fine paying for both a site plan and a workspace. - Ghost: writers and publishers whose business is the newsletter and paid membership. - VeloCMS: solo creators and small teams who want blog, newsletter, membership and a small store in one hosted login and don't need a large plugin catalog. When is each option the wrong choice? Wix is the wrong choice when portability matters, when you need unusual backend behavior, or when your content volume could outgrow the collection caps for the plan you can afford. It's also awkward if you expect a deep search-engine tuning project, though as noted its built-in controls are better than the reputation suggests. WordPress is the wrong choice when nobody wants to be responsible for updates, backups and plugin hygiene, or when a client will be editing unsupervised and could break things. The vulnerability numbers above are the reason. VeloCMS is the wrong choice if you need to self-host, rely on a particular WordPress plugin, need custom post types or structured data in git, or want a permanent free tier to experiment on. It's also the wrong pick for a salon-type business whose revenue depends on a deep booking system, because it's a blog-first product with a short integration list, and I haven't compared its integrations against Wix Bookings feature by feature. And if your current site works, staying put is often the cheapest thing you can do. Migration costs URLs, plugin data and retraining before it returns anything. FAQ Is Wix easier than WordPress? For a first site, usually yes. Wix bundles hosting, a certificate, support and an editor, and its new Harmony editor adds an AI agent for prompts. In my opinion WordPress is only as easy to start as your host's installer and your familiarity with a page builder (Wix help: Harmony). Is Wix cheaper than WordPress? Depends how you count. Wix's paid plans run from $17 to $159 a month on yearly billing (Wix plans); WordPress software is free and hosting can start under $3 a month on a long promo that renews higher (Hostinger). WordPress is cheaper on paper if you maintain it yourself. Can I move my Wix site to WordPress? Not as a whole. Wix says its sites must run on Wix's servers, and its help center says blog posts can't currently be exported to other platforms. You can export CMS collections as CSV and rebuild the design on the new platform, so plan the move as a rebuild (Wix help). Is WordPress safer than Wix? Neither is "safe" by default. Patchstack found 91% of 2025's new WordPress vulnerabilities in plugins and 6 in core, so a lean, updated install is fine and a neglected one isn't (Patchstack). Wix removes the plugin-patching job but you're trusting Wix's own security claims (Wix blog). Can Wix handle SEO as well as WordPress? For most small sites, yes: it lists editable robots.txt, canonical tags, structured data, slugs and 301 redirects (Wix SEO features). WordPress offers more headroom through plugins and server control, and whether you need it depends on how unusual your SEO plan is. Does Wix charge transaction fees? The plans page I read lists none, but the processor does: in the US, Wix Payments lists 2.9% plus $0.30 for standard cards (Wix Payments fees). Squarespace adds platform fees on cheaper plans (Squarespace pricing). What if I only need a blog? A blogging-focused tool may fit better than either. Ghost is the newsletter specialist, WordPress has the deepest blogging ecosystem, and VeloCMS is another option if you also want memberships or a store. Test with a real post before paying. Last verified: 2026-10-11. Sources (all read on 2026-10-11) Wix: plans · Wix Payments fees · export and external hosting · blog export · custom code · Velo · Harmony editor · free site · collection limits · SEO features · App Market · hosting article · premium plans article WordPress: about and license · plugin directory · Yoast SEO listing · recommended hosts · upgrade and auto-update docs · export screen docs · WordPress.com pricing · Hostinger WordPress hosting Security and usage data: Patchstack 2026 report · W3Techs CMS usage Alternatives: Squarespace pricing · Squarespace export · Webflow pricing · Ghost pricing · Ghost on GitHub · VeloCMS pricing · VeloCMS marketplace · VeloCMS themes · VeloCMS for developers · VeloCMS Wix import guide · VeloCMS WordPress import guide <!-- fact-check 2026-10-11 (independent checker; every claim re-read against the vendor page today, US view where currency applies): CORRECTIONS (10) 1. Wix prices: "$17.77 / $29.77 / $39.77 / $159.77, USD for reference only, Price includes GST" -> "$17 / $29 / $39 / $159, yearly billing, before tax". The original was read from a non-US page (GST-inclusive converted figures). Sources: wix.com rendered for a US visitor (Wix's own Prices collection record "US": light 17, core 29, business 39, businessElite 159, currency USD) and wix.com ("range from $17 to $159 per month when billed annually"). Wording about GST/"for reference only" removed; added "prices and currency vary by location and Wix sometimes promotes discounts". 2. Wix Core annual arithmetic: "$29.77 x 12 = $357.24" -> "$29 x 12 = $348". Source: same as 1. 3. FAQ "Is Wix cheaper": "$17.77 to $159.77" -> "$17 to $159". Source: same as 1. 4. Squarespace prices: GBP (12/17/29/79; monthly 16/24/36/89) -> USD annual 16/23/39/99, monthly 21/32/48/119 (read by switching the page currency to USD on squarespace.com). Fee ladder (2%/0%/0%/0% store, 7/5/1/0% memberships), 14-day trial, no card required were re-confirmed unchanged. 5. Squarespace export link: support.squarespace.com (404 on HEAD) -> support.squarespace.com (resolves 200). Export inclusions/exclusions re-confirmed. 6. Ghost(Pro) Starter: "$18 a month billed yearly" -> "$15 a month billed yearly". Source: ghost.org (Publisher $29 and Business $199 unchanged). 7. Webflow: removed "CMS items ranging from 50 on Starter up to 20,000 on Premium" (not on the current pricing page; unverifiable) -> Starter has a limited CMS, Premium adds the full CMS, Basic is for sites without a CMS. Source: webflow.com. 8. Webflow export: "dynamic content (CMS items and pages) can't be exported" -> "dynamic content has to be exported collection by collection and forms stop working" (that is what the pricing-page FAQ now says). Workspace Core $19/mo billed yearly with code export re-confirmed. Source: webflow.com. 9. W3Techs: the old sentence mixed two bases (58.6% known-CMS share vs "6.1% for Wix" etc. presented as if all-websites). Now stated explicitly: WordPress 40.1% of all sites = 58.6% of known-CMS sites; Shopify 7.8%, Wix 6.1%, Squarespace 3.6%, Webflow 1.2% on the known-CMS basis (Wix 4.2% of all sites). Source: w3techs.com (numbers themselves were correct). 10. WordPress export: "Media files are a separate download, and a theme's design doesn't travel in that file" was not in the cited docs -> reworded to what the docs say (lists content types, silent on media/theme). Source: wordpress.org. Also: the Wix SSL claim is now attributed to the hosting article (wix.com: "Free HTTPS and automatic SSL certificate installation"), since the premium-plans article does not mention SSL; two "in my experience" lines (Webflow learning curve, WordPress ease of start) relabelled "my opinion"; VeloCMS entry trimmed (155 -> ~145 words) for length parity with neighbours (GEO standard 2.6); "newsletter and membership paywall" wording kept to what velocms.org/pricing lists. VERIFIED UNCHANGED (source read today): Wix Payments US fees 2.9%+$0.30 / Amex 3.7%+$0.30 / PayPal 3.49%+$0.49 / Afterpay+Klarna 6%+$0.30 / cross-border 1.5% / conversion 1% (support.wix.com/en/article/service-fees-for-wix-payments; no plan dependency stated); Wix export, blog-export, free-site, collection-limit, custom-code, Velo, Harmony/Aria, CSV-export, SEO (500 URL redirect import), App Market sub-counts (22/35), hosting-blog claims (99.99%, 200+ CDN nodes), premium-plans blog; WordPress.com plans $4/8/25/45 yearly and $9/18/40/70 monthly (public-api.wordpress.com plans endpoint, USD), 1 GB free plan, 60,000+ plugins, Commerce 0% transaction fees, SFTP/SSH/Git on Business; WordPress.org about (GPLv2+), hosts (Pressable, Bluehost, Hostinger), plugins page (no total), Yoast 10+ million, upgrade docs (5.6 auto core updates), Hostinger Premium $2.99 / 48 months renewing $10.99; Patchstack 11,334 / 91% / 9% / 6 core / 46% / 5 hours; Ghost MIT + README lists Ghost(Pro) first; VeloCMS pricing page ($9/$29/$69, 14-day trial, card at signup, no charge until day 15, 5 GB / 5,000 subscribers on Pro, 40 themes, 0% platform fee on reader purchases), marketplace "57 free integrations" (all "by VeloCMS Team"), for-developers (managed SaaS only, third-party plugin dev not open, JSON or CSV export), Wix import guide (30 pages per run, drafts), src/lib/plans/limits.ts (no free plan, 14-day trial). No "free plan" wording about VeloCMS remains except the statement that there is none. All 35 links resolve (200 on GET). COULD NOT FULLY VERIFY / CAVEATS: (a) A third-party site (usagepricing.com) claims Wix raised Light from $17 to $19 on 2026-08-27; Wix's own plans page, its Prices record (updated 2026-08-30) and its blog still show $17, so the article follows Wix. Re-check Light before publishing if the date slips. (b) Wix's WebFetch view showed "Save 50%" promo labels on some tiers; the US page view showed none, so list prices are used and the article tells readers promotions may apply. (c) "Hosting, SSL and 24/7 support included" for Squarespace comes from its pricing-page FAQ text, not a plan-card line. (d) The Wix plan-feature tiering (payments from Core up) was read from the plan cards, which carry no tooltips; the Wix compare-features table was not opened. -->
What is the best free CMS platform? The best free CMS depends on which kind of free you mean. If you're happy to run a server, self-hosted WordPress is the safest default and Ghost is the nicer place to write. If you want no server and no monthly bill at all, Hugo or Astro on a free static host is the cheapest setup that really works. If you want a no-code start, the free tiers of WordPress.com and Wix will get you online, with real limits attached: no custom domain on either, and a banner ad on Wix. Disclosure: I work on VeloCMS, and it's in the list. It sits last, gets an entry about the size of its neighbours' (weaknesses included), and I'll say plainly that it is not the pick for a zero-budget reader. It has no free plan, only a 14-day trial. Last verified: 2026-10-10. A note on method. Every price, limit and licence below was read from the vendor's own page or repository on 2026-10-10. Where a page wouldn't load or wouldn't show a clear number, I say so instead of quoting a number from memory. Wix's paid prices rendered in a currency I couldn't pin down, so I've left them out. Opinions are marked as mine. What does "free CMS" actually mean? Three different things hide behind the phrase, and mixing them up is how people end up with a surprise bill or a site they can't grow. The first is free software that you host yourself. The licence costs nothing and you can run it anywhere, but you supply the server, the database, the updates and the backups. WordPress.org, Ghost, Strapi, Decap CMS, Hugo and Astro all live here. The second is a free tier of someone else's hosted product. Nothing to install, nothing to patch, and the vendor decides the walls: a subdomain instead of your own address, a page cap, a bandwidth cap, a banner, a feature that's switched off until you pay. WordPress.com, Wix, Webflow and Sanity are in this group. The third is the stuff that stays on the bill whichever of the first two you pick: the domain name, email delivery, backups, monitoring, and your own hours. That one has no logo and no pricing page, which is why it gets forgotten. Keep those three apart and the question gets easier. "Best free CMS" for a hobby blog and "best free CMS" for a client who will pay you for the site are different questions with different answers. Is a free CMS really free? Rarely, once the site is live. The software can be zero. The things around it usually aren't. Ghost does something unusual for a vendor: it prints the arithmetic. Its hosting documentation (accessed 2026-10-10) says self-hosting starts "from $10/mo" for a server with at least 1 GB of memory, then lists services you'd add separately: a CDN and WAF from $20/mo, newsletter delivery from $15/mo, analytics from $10/mo, backups from $5/mo and an image editor from $12/mo. Ghost sells the managed version, so read the list with that in mind, but the categories are the useful part. A self-hosted anything needs a server, a way to send email, a place to put backups and something to tell you when it falls over. Domains are the other classic. A few vendors throw in a first-year domain: WordPress.com says all paid plans include a free custom domain for the first year, Wix's domains help page offers a one-year domain voucher with select yearly or multi-year Premium and Studio plans, and Ghost's pricing page carries a one-year .link domain promotion with the line that registrations "renew at standard rates after the first year." If you'd rather buy it yourself, Cloudflare says its registrar does not mark up domain prices, charging only what registries and ICANN charge (the page gives no example price, so check the live number for your extension). Email is where free tiers run out fastest. Resend's free plan allows 3,000 emails a month with a 100-a-day cap and three domains, and its Pro plan starts at $20 a month for 50,000. Brevo advertises a free-forever plan that sends up to 300 emails a day once your account is approved. Those are fine for a form notification or a small list, and tight for a weekly newsletter that's growing. Then there's time, which I'd call the biggest line item and which no vendor prints. A self-hosted stack means you are the on-call engineer for your own blog. That's a fair price for some people (it's how many of us learned) and a poor one for a small business owner who just wants the contact form to work. The free tiers of hosted products flip the trade: your time is safe, your options are narrow. Which free CMS is best for a blog? Here's a quick map before the detail. Each line is a situation, then my pick and the reason. - You want the biggest ecosystem and don't mind running it: WordPress.org, because plugins, themes and freelancers are everywhere. - You want a clean writing tool and a newsletter, and can run Node: self-hosted Ghost. - You're a developer and want the cheapest, fastest-loading site: Hugo or Astro on a free static host. - You want an editing screen on top of a static site: Decap CMS, if you're comfortable with Git. - You want zero setup and zero cost, and accept the walls: WordPress.com free or Wix free. - You're building something custom and need an API: Strapi (self-hosted) or Sanity's free plan. - You want blog, newsletter, membership and shop under one login and can pay: a paid all-in-one, which is where VeloCMS sits. Now the options one at a time, in the order I'd reach for them on a zero budget. The options, one at a time WordPress.org (self-hosted WordPress) WordPress is licensed under the GPL (version 2 or later, per wordpress.org), which gives you the freedom to run, study, change and redistribute it. That's the "free" you can bank on: nobody can switch it off or put it behind a plan. Its requirements page recommends PHP 8.3 or greater, MariaDB 10.11 or greater or MySQL 8.0 or greater, Apache or Nginx, and HTTPS. The strength is the ecosystem. If a problem exists, someone has probably written a plugin or a tutorial for it, and finding a person who knows the system is easy. The weakness is that the ecosystem is also the maintenance surface. You pick the host, you keep PHP and the database current, you choose plugins and vet them, and you test restoring backups. I haven't priced hosting here because the range is wide enough that any single number would be a guess. Budget something for it, and don't confuse this with WordPress.com, which is the next entry and a different product. WordPress.com (the hosted free plan) The free plan gives you a WordPress.com subdomain, 1 GB of storage, unlimited pages and posts, and basic stats for the last seven days. The pricing page also lists what it doesn't include: a custom domain, plugin installation, ad-free browsing for visitors, and premium themes. So it's a decent place to try writing and a poor place to build a brand. The first paid step is Personal at $4 a month on annual billing ($9 month to month), which brings 6 GB and a free custom domain for the first year. Business is $25 a month on annual billing ($40 monthly) with 50 GB, and Commerce is $45 on annual billing. My read is that the free plan works better as a long trial than as a place to stay. Ghost (self-hosted, MIT-licensed) Ghost's repository is released under the MIT licence, and the project describes itself as independent technology for publishing, memberships, subscriptions and newsletters. If you like a focused editor and want newsletters and paid memberships in the core product, that focus is its appeal. Be clear about what "free" means here. Ghost(Pro), the hosted service, has no free plan: Starter is $18 a month billed yearly for 1,000 members and one staff user, Publisher is $29, and Business is $199. The page shows "Try for free" buttons, which I read as a trial (I couldn't find a trial length on the page, so check before you sign up). The free route is self-hosting, and the docs list an Ubuntu LTS server, Node.js 22, MySQL 8, NGINX and at least 1 GB of memory, plus the extra services priced above. Updates and security patches are yours. Great for a technical writer, a real chore for everyone else. Strapi (open-source headless CMS) Strapi's Community edition is MIT-licensed and, per its pricing page, includes role-based access, REST and GraphQL APIs and unlimited entries and API calls. The page says what stays out of the free edition: Strapi AI, Live Preview, Releases and Content History. Growth, the first paid tier, is $45 a month for three seats. Think of it as a backend, not a website. You get an admin panel and an API, and you build the front end yourself. The CLI install docs require an active or maintenance LTS version of Node, default to SQLite, and list MySQL, MariaDB, PostgreSQL and SQLite as supported databases, each with a recommended version. They also note that self-hosted content sits by default in a SQLite file in a .tmp folder, which is a good reason (my view) to set up a real database before you have real content. I found no free tier on Strapi Cloud; its plans start at $35 per project per month. It's a strong pick if your project is more app than blog, and overkill for a personal site. Hugo and Astro (static site generators) These are free in the strongest sense: tools that turn files into a folder of HTML, which any static host serves. Hugo is Apache-2.0 licensed per its GitHub repository and calls itself the world's fastest framework for building websites (a vendor claim, not my measurement). Astro is MIT-licensed, describes itself as a framework optimized for fast, content-driven sites, and ships adapters for Netlify, Vercel, AWS and other hosts. What you get is a site that can sit on a free host, a version history for free because it's all in Git, and almost nothing to patch at runtime. What you give up is an admin screen. There's no login for a non-technical editor, no built-in forms, comments or members, and anything dynamic means bolting on a third-party service. For a developer's blog or documentation, I'd pick this first. For a client who wants to edit their own pages, I wouldn't, at least not without the next entry. Decap CMS (a Git-based editor for static sites) Decap CMS is the answer to "can a static site have an editing screen?" It's MIT-licensed and describes itself as open source content management for your Git workflow: content stays in your repository, and the editor writes commits to it. It works with generators such as Hugo and Jekyll. The catch is in the plumbing. The open-source version asks you to bring your own authentication and backend, so you'll configure a Git provider and a login path yourself. The site mentions a paid extension, Decap Turbo, for hosted authentication and roles, which is useful to know about and not something I've priced. Decap suits a small team that already lives in Git and wants an editor for the one or two non-developers. It's less comfortable if "Git" is a foreign word to your editor. Wix (free plan) Wix offers a free plan with no credit card required, a drag-and-drop editor and a template library. The free site lives at a Wix-branded address instead of your own domain. Wix's support page on the banner ad says a free site includes a Wix banner ad at the top and a Wix favicon in the browser tab, and that removing the banner needs a Premium plan. It's the lowest-effort way for a non-technical person to get a visual site online, and for a first look at whether you want a website at all, that's useful. It's a weak choice for a serious blog, because the cap isn't a feature cap but an identity cap: your readers see Wix's address and Wix's ad. Wix says select yearly Premium plans come with a free domain for a year. I won't quote the plan prices, because the page showed them in a currency and tax format I couldn't confirm. Webflow (Starter plan) Webflow's free Starter site plan, per its pricing page, gives you a webflow.io domain, a limited CMS, 2 static pages, 1 GB of bandwidth and 50 form submissions. That's a sandbox. The next step, Basic, is $15 a month billed yearly and adds a custom domain, 300 static pages and 10 GB of bandwidth and is pitched at simple sites that don't need a CMS, while Premium, the plan that adds the CMS, is $25 a month billed yearly. Webflow is a design tool first. If you care about pixel-level control without writing code, nothing else here comes close, and a free Starter site is a good place to find out whether you can stand the learning curve (my view). I couldn't open Webflow's help article on plan limits (it returned a 403), so I'm quoting only what the pricing page lists. A blog on this free plan would hit the page and CMS ceilings fast. Sanity (free plan) Sanity is a hosted content backend, and its free plan has the most room of the hosted ones here. The page lists up to 20 user seats (administrator and viewer roles only), 2 public datasets, 10,000 documents, 1M API CDN requests and 250,000 API requests a month, plus 100 GB of bandwidth and 100 GB of asset storage. Studio hosting is free, and the page I read mentions no restriction on commercial use. Growth is $15 per seat per month. The free plan has fences worth knowing about: both datasets must be public, draft change review is limited to three days, and full history isn't included. The deeper catch is the same as Strapi's: Sanity stores content and serves an API, and somebody has to build the website. Pair it with Astro or Next.js and a free static host and you have a capable free stack, assuming you can build it. (Storyblok has a free Starter plan on its pricing page, and Contentful's help center has a page about its Free plan, but Contentful's pricing page rate-limited my requests, so I'm not quoting numbers for it.) VeloCMS (no free plan, 14-day trial) VeloCMS is a hosted all-in-one: blog, newsletter, membership and a small store under one login. It has no free plan. Pro is $9 a month, Business $29 and Agency $69, in USD, and every plan starts with a 14-day trial. The pricing page says a card is collected at signup and nothing is charged until day 15; cancel before then and you pay nothing. Pro lists a custom domain, a visual page builder with 70+ blocks, membership and paywall with a 0% platform fee, a newsletter, e-commerce and a WordPress migration wizard, within 5 GB of storage, 5,000 subscribers and one site. Reader payments run through your own Stripe or PayPal account, so their fees still apply. Now the limits. The developer FAQ says the application is proprietary and managed-only: no self-hosted edition and no source access. It also says third-party plugin and theme development isn't open yet. It's a much younger product with a smaller community than WordPress. For a zero-budget reader it is the wrong answer: every other option above can be free, and this one can't. Where can you host a free site for free? Static sites (Hugo, Astro, Decap's output) can sit on free hosts, and the differences between the hosts matter more than the marketing suggests. Cloudflare Pages lists, for its free plan, 500 builds a month, one build at a time, up to 20,000 files per site, a 25 MiB cap per asset, 100 projects per account and 100 custom domains per project. Its Functions pricing page says static asset requests are free and unlimited on free and paid plans, while Functions requests count against the Workers Free quota of 100,000 requests a day. For a blog, the 25 MiB asset cap is the one you'd hit, with a video. Netlify's free plan is $0 and credit-based: 300 credits a month, with custom domains and SSL, unlimited deploy previews and a global CDN. The page shows a Personal plan at $9 a month (1,000 credits) and Pro at $20 a month (3,000 credits). The exact conversion from credits to traffic isn't on the page I read, so measure your own usage before you rely on it. GitHub Pages has a 1 GB size limit on published sites, a soft 100 GB-a-month bandwidth limit and a soft limit of 10 builds an hour. The part that matters for a business: its docs say Pages isn't intended for or allowed as a free hosting service to run your online business, an e-commerce site, or any site primarily directed at commercial transactions or commercial SaaS. A personal blog is fine. A shop isn't. Vercel's Hobby plan is free and roomy (100 GB of fast data transfer, a million CDN requests), but its docs say it restricts users to non-commercial, personal use only. If the site earns money or belongs to a business, read that before you deploy. A practical zero-cost stack, then: Astro or Hugo for the site, Git for history, Cloudflare Pages or Netlify for hosting, a domain you register yourself, and Decap or Sanity if someone non-technical must edit. The cost is a domain, plus the afternoon you spend wiring it. Who is each option best for? - WordPress.org: people who want the largest ecosystem and will own the hosting and patching. - WordPress.com free: someone testing the waters who doesn't need a custom domain or plugins. - Ghost (self-hosted): technical writers who want newsletters and members in one focused tool and can run a Node server. - Strapi: developers building an app or custom site who want a self-hosted API with an admin panel. - Hugo or Astro: developers who want a fast, cheap, version-controlled site with no admin screen. - Decap CMS: small teams already in Git who need an editing screen for one or two non-developers. - Wix free: a first, throwaway look at having a website, accepting a Wix address and a Wix ad. - Webflow Starter: designers evaluating Webflow before paying, not real publishing. - Sanity free: developers with a front end in mind who want a hosted content backend without a bill. - VeloCMS: a solo creator or small team who has decided to pay for blog, newsletter, membership and shop in one login, and who doesn't need WordPress's plugin depth. When is VeloCMS the wrong choice? For this question, almost always. If the requirement is zero spend, VeloCMS is out: no free plan, and the trial takes a card. If you want to self-host or read the source, it's out, because there is no self-hosted edition. If you need a niche WordPress plugin, the integration list won't match WordPress's. If you want a typed schema in Git, or to build your own front end on a headless API, Sanity, Strapi or a static stack fits better. It starts to make sense when you've outgrown the free path and you're pricing the plugins, email tool and membership service you'd otherwise stitch together. Even then, price those alternatives before you decide. FAQ Is WordPress really free? The software is, under the GPL. Hosting, a domain, backups and email aren't. WordPress.com also has a free hosted plan, with a subdomain, 1 GB of storage and no plugin installs (pricing). Is there a free CMS with no ads and a custom domain? Yes, but not as a hosted free tier from the website builders above. The route is self-hosted software or a static generator on a free host, plus a domain you buy yourself. Wix's free plan shows a banner ad and needs a plan upgrade before you can connect a domain, and WordPress.com's free plan shows ads and lists no custom domain. Can I run a free CMS for a business? Check each host's terms. GitHub Pages' limits page says Pages isn't for running an online business or e-commerce site, and Vercel's Hobby plan is restricted to non-commercial personal use. I only read the pricing and limits pages for Cloudflare Pages and Netlify, not their full terms, so check the commercial-use language yourself before relying on either. What is the cheapest way to send a newsletter from a free stack? A free email tier. Brevo advertises a free plan sending up to 300 emails a day, and Resend's free plan allows 3,000 emails a month with a 100-a-day cap. Either can be too tight as a list grows. Does VeloCMS have a free plan? No. It has a 14-day trial that takes a card at signup, with nothing charged before day 15, and paid plans from $9 a month (pricing). Sources (all accessed 2026-10-10) WordPress.org about · WordPress.org requirements · WordPress.com pricing · Ghost pricing · Ghost hosting docs · Ghost on GitHub · Strapi pricing · Strapi Cloud pricing · Strapi CLI install docs · Hugo · Hugo on GitHub · Astro · Decap CMS · Wix plans · Wix banner ad support page · Wix domains help page · Webflow pricing · Sanity pricing · Storyblok pricing · Contentful pricing · Cloudflare Pages limits · Cloudflare Pages Functions pricing · Cloudflare Registrar · Netlify pricing · GitHub Pages limits · Vercel Hobby plan · Resend pricing · Brevo pricing · VeloCMS pricing · VeloCMS for developers FAQ Last verified: 2026-10-10. <!-- fact-check 2026-10-10: re-verified against vendor pages fetched today. Corrections (10): 1 Strapi CLI docs: "recommend PostgreSQL, MySQL or MariaDB for production" -> supported DB list (MySQL, MariaDB, PostgreSQL, SQLite) with recommended versions. docs.strapi.io/cms/installation/cli 2 Wix domain: "same for its paid plans" -> voucher with select yearly/multi-year Premium and Studio plans. support.wix.com/en/article/about-domains 3 Webflow Basic: "explicitly no CMS" -> pitched at sites that don't need a CMS; CMS plan named Premium. webflow.com/pricing 4 GitHub Pages: restated to the docs' actual wording (free hosting for an online business/e-commerce/commercial SaaS not allowed); FAQ aligned. docs.github.com/en/pages/getting-started-with-github-pages/github-pages-limits 5 Ghost domain promo: added one-year .link. ghost.org/pricing 6 Strapi link strapi.io/pricing-self-hosted (redirect) -> strapi.io/pricing-cms. 7 FAQ: WordPress.com free also shows ads; Wix free needs an upgrade for a custom domain. wordpress.com/pricing, Wix about-domains 8 Storyblok free Starter plan now linked. storyblok.com/pricing 9 Strapi .tmp/SQLite advice marked as opinion. 10 VeloCMS entry trimmed 202 -> 179 words (Sanity 186); no claim changed. Verified vs messages/en.json, velocms.org/pricing and /for-developers: $9/$29/$69, 14-day trial, card at signup, no charge until day 15, no free plan, 5 GB / 5,000 subscribers / 1 site. All other prices, limits and licences matched the vendor pages. All 28 original URLs resolve (Contentful pricing 429 = bot block, link kept). Unverifiable: Contentful free-plan numbers (429; none quoted); WordPress.com Free card read from the rendered page only; Wix paid prices deliberately omitted; dynamic prices can change after today. -->
Is Wix a CMS website? Yes, with one caveat. Wix is a hosted website builder that includes a CMS, so a Wix site is a CMS website in the everyday sense: you edit content in a dashboard, store it in structured collections, and publish it through templates. The caveat is that the CMS is a feature of the Wix platform, not a separate piece of software you can install, host or swap out. The pieces are the Wix CMS (formerly called Content Manager), dynamic pages, the Wix Blog app, Velo for code (always on in Wix Studio, a Dev Mode switch in the regular Wix Editor), and a separate Wix Headless product for people who want their own frontend. Everything runs on Wix's servers, and Wix's own help center says so in as many words. Disclosure: I work on VeloCMS, which shows up in the comparison section. It gets an entry about the same size as everyone else's, weaknesses included, and there's a section near the end on when it's the wrong pick. Last verified: 2026-10-09. Method: prices, limits and features come from the vendors' own pages, read on 2026-10-09, and each links to its source inline. Where a page gave no number, I say so. Opinions are marked as mine. What does "CMS" mean, and how does Wix fit it? A content management system separates what a site says from how it looks. You keep content (posts, products, listings) in a structured store, design templates once, and the system pours the first into the second. WordPress, Ghost and Webflow all work that way. So does Wix, once you use its CMS. Where Wix differs is packaging. With WordPress.org you download software, put it on a host and run it. With Wix there's nothing to install and nothing to patch, because the builder, the database, the hosting and the editor are one product. Wix's developer docs describe the Wix CMS as "a built-in document-oriented database optimized to store and retrieve website content" that is "globally replicated" and "fully managed by Wix." Around it sit datasets (invisible components that wire page elements to data), dynamic pages and a Data API. An independent tracker treats Wix as a CMS, too. W3Techs lists it in its content management category and put it at 4.2% of all websites in its October 2026 report, behind WordPress at 40.1% and Shopify at 5.4% (W3Techs, accessed 2026-10-09). So the honest short version: Wix is a website builder first, and a CMS is one of the things inside it. If your question is "can I manage structured content on it," yes. If it's "can I treat it like a standalone CMS I can move around," mostly no, and the rest of this post is about why. What can you actually manage with the Wix CMS? You manage collections. A collection is a database table with typed fields, and you can view it as a table, list or gallery. Wix's CMS overview (accessed 2026-10-09) lists permissions on who can view, add, edit or delete, saved views, indexes, backups to restore earlier collection versions, and a sandbox for testing before you go live. It also supports CSV import and export. The payoff is dynamic pages. You design one page layout and Wix generates a page per collection item, with the URL deciding which item to show. Wix's docs describe list pages, item pages and category pages, and the item-page version gives "every collection item a unique URL." The same overview gives a total pages quota of 298, up to 100 of them static. Each dynamic page you add counts as one page, but the page says dynamic pages can generate unique URLs for every item in a collection without consuming additional pages, so the item caps below are the limit that bites. Wix's own apps use the same machinery: Stores and Bookings write their data to collections. These app collections are read-only in the CMS (edits happen in the app), can be exported to CSV, and don't count toward the item cap, per the data limits page. Blogging is its own app. Wix Blog covers multiple writers with assigned roles, categories, hashtags, comments and likes, SEO and AI tools, translations, subscription options for members-only posts and blog analytics. Wix's developer docs say a blog can hold up to 100,000 posts, and a post can sit in up to 10 categories. How many items can a Wix site hold on each plan? This is the number people find out too late. From Wix's collection limits page (accessed 2026-10-09), items across your collections: - Free site: 1,000 items on a regular account, 10,000 if the site belongs to a Wix Studio account - Light: 1,500 items - Core: 4,000 items - Business: 20,000 items - Business Elite and Elite: 10,000,000 items Database storage is 10 GB across Light, Core and Business, and 100 GB on Business Elite and Elite (effective January 7, 2026). Each item is capped at 512 KB across its fields combined, excluding media files. One gotcha from the data feature limitations page: if you downgrade, nothing is deleted, but you can't add items until you're back under the lower quota. What are Wix Studio and Velo for developers? Wix sells more than one editor. The regular Wix Editor is for site owners, while Wix Studio is pitched at agencies and professionals building client sites. Velo is the development layer in both: Wix describes it as "an open development platform" where you "work in Wix's visual builder, add custom functionality and interactions using Velo's APIs, use your own tools, and enjoy serverless coding in both the front-end and backend." In the regular editor you turn it on with Dev Mode in the top bar. In Studio, per the Wix Studio coding docs, coding is always available. Once you're in, the docs describe frontend and backend JavaScript (backend files become web modules your pages call), data hooks, scheduled jobs and custom routers, npm packages, outside calls with fetch, a secrets manager, HTTP functions for your own endpoints, and external databases connected through an adapter so they look like ordinary collections. There are three places to write it: the built-in Code panel for small jobs, the browser-based Wix IDE (VS Code-based) for bigger projects, and a local setup where the Git integration and Wix CLI connect the site to GitHub so you can work in your own editor and publish from the command line. The Wix IDE can't be used while GitHub integration is on, though you can disconnect and switch back. The compute side has real ceilings. On a free site, per Wix's free-site limits, you get one micro backend container (1 vCPU, 400 MB of RAM), 60 backend requests a minute and a 14-second timeout. Data requests are capped at 1,000 reads and 60 writes a minute, and the data limits page adds that each include() in a query counts toward the request quota (its own example query counts as 3 reads). Paid plans raise these, but neither Wix's data-limits nor compute-limits page prints per-plan numbers for requests, only the free-tier table above, so check Wix's plan comparison before you size a project. My take: Velo is a real development environment, handy for bolting a custom form handler onto a site a non-developer will keep editing. It's not the place to port a large application, and the quotas say so. Can you use Wix as a headless CMS? Partly, and it depends what you mean by headless. Wix Headless lets you "build a custom frontend on any stack you choose and power it with production-ready Wix business APIs," and the list of solutions it names includes eCommerce, bookings, events, memberships, CMS and blog. There are two routes. Wix recommends Wix-managed headless, where Wix hosts and runs your frontend; build it with Astro and you also get automatic authentication, SEO support and extensions. In self-managed headless you host wherever you like, call Wix from the JavaScript SDK or REST API, and handle authentication yourself (so does any managed setup that isn't Astro). Wix says Headless is free to start, with a premium plan unlocking payments, custom domains and more. For a blog, the Posts API is the relevant piece. The docs list endpoints to get, list and query posts, and listing returns up to 100 published posts per request. Reads need the Read Blog permission scope, and you can create admin API keys in the API Keys Manager, scoped to all sites or only the ones you select (how to generate one). What it isn't: a classic content API you can point a static site at and forget. Your content still lives in Wix, Wix's data quotas apply to anything making data requests, and you depend on Wix's auth model. If you want Git-backed Markdown that runs anywhere, that's a different category of tool. What are the real limits of Wix as a CMS? Can you export or move a Wix site? This is the limit that matters most. Wix's help article on exporting says it plainly: "Since Wix is a SaaS solution, your site must run on Wix's servers." You can connect a domain you bought elsewhere (paid plan needed), and the article says the content belongs to you, but the site itself stays on Wix. There's no zip of HTML, CSS and a database to carry to another host. For content, the picture is patchy: - Blog posts: Wix's help center lists exporting posts to other platforms as a feature request and says it is currently not possible. The blog's RSS feed shows only the latest 20 posts, so it won't carry an archive. - CMS collections: they do export. From More Actions you choose Export to CSV, and you can export everything (including hidden and system fields), only the rows matching a filter, or just the structure. Non-Latin text is UTF-8 and time fields come out as UTC+0, per Wix's export guide. That page doesn't say how media fields or reference fields are written out, so test with a small collection before you rely on it. - Images and video: the Media Manager lets you download files you uploaded, several at a time within a folder, but not everything in one click, and free Wix and Shutterstock media can't be downloaded. - Redirects: the redirect manager imports up to 500 redirects per CSV file and exports your whole list. Getting in is easier than getting out. Wix's WordPress importer takes up to 5,000 posts per run, from a blog URL or an XML file, with an overall Wix Blog cap of 100,000. It brings text, images, video, original dates, alt text and categories. It skips tags, author names, comments, forms, plugins, custom code and attached documents. Notice the asymmetry. What about SEO controls? Wix's SEO features page (accessed 2026-10-09) lists an editable robots.txt, automatic structured data for products, events and blog posts that you can customize, editable canonical tags, title and meta description control, open graph tags, editable URL slugs on pages, product pages and blog posts, and a URL Redirect Manager for 301s. You work through Wix's settings screens and, if you code, Velo, not server configuration. And performance? Third-party field data is kind to Wix. The Core Web Vitals tracker at corewebvitals.io reports Q2 2026 mobile data in which 94.4% of Wix sites passed Largest Contentful Paint (p75 of 1.6 s) against 69.5% for WordPress (2.7 s) and 85.8% for Squarespace (2.2 s). Webflow and Ghost showed 100% from tiny samples (15 and 37 sites). It compares populations, not your site. What other limits should you plan around? Collection caps by plan, covered above, and a CMS request timeout of 5 seconds on most plans (10 seconds on Business Elite and Elite), so a slow aggregate query fails instead of limping along. Sandbox and live collections also have to be synced deliberately, and Wix warns a sync can't be undone. None of that hurts a brochure site or a modest blog. It bites where you'd normally reach for a real database. How much does Wix cost right now? From wix.com (accessed 2026-10-09), shown in USD per month with yearly billing paid in full at purchase: - Free: $0, with a Wix address. Wix says a free site has 500 MB of storage and 1 GB of bandwidth, shows Wix ads, and needs an upgrade to connect a custom domain - Light: $17.77 a month, 2 GB storage, 2 site collaborators - Core: $29.77 a month, 50 GB storage, accept payments, basic eCommerce, scheduling and services, 5 collaborators - Business: $39.77 a month (marked Recommended), 100 GB storage, standard eCommerce, 10 collaborators - Business Elite: $159.77 a month, unlimited storage, advanced eCommerce, 100 collaborators The page contradicts itself on tax. Each card says "Price includes GST," while the footnote says "Prices and currency vary by location. USD prices displayed for reference only," and adds that prices do not include applicable taxes, which are set by your billing address. A Wix blog post lists the same four plans as $17, $29, $39 and $159. So treat the cents as noise and read the number on the purchase page, which Wix says shows the final price before you pay. Wix offers a 14-day money-back guarantee, and selected yearly plans come with a one-year free domain voucher (first purchase of a site plan only, usable on a new domain or a domain transfer). Wix Studio has its own site plans, named Basic, Standard, Plus and Elite. Wix's help page lists their allowances (CMS items match the numbers above, with 3, 5, 10 and 100 client accounts) but no prices, and the Studio plans page shows none in the HTML I could read. Wix's own blog posts disagree with each other on Studio prices (Basic at $12 in one, $19 in another), so I won't quote any. When is Wix the right CMS, and when should you pick something else? Wix is a strong pick when you want a good-looking site with a blog, bookings or a small shop, the owner edits it, and nobody wants to maintain hosting. It's weaker when portability matters, when your content runs into the item caps above, or when your workflow is code-first. Each entry below covers what it's good at, what it costs and what you give up. WordPress.org (self-hosted) wordpress.org is free software under the GPLv2 or later, built on PHP and MariaDB, and runs on whichever host you pick. That's the opposite of Wix on ownership: you hold the files and the database, with a huge plugin and theme ecosystem. The software is free, hosting is a separate bill from whichever host you pick, and patching and backups are yours. WordPress.com wordpress.com is managed WordPress. Its pricing page (accessed 2026-10-09) shows a Free plan with 1 GB and a WordPress.com subdomain, and USD prices that drop with longer commitments: Personal $9 monthly or $4 a month yearly, Premium $18 or $8, Business $40 or $25, and Commerce $70 or $45. It advertises more than 60,000 plugins on its paid plans. You get the WordPress content model with someone else running the servers, and the trade-off is working inside WordPress.com's plan and hosting rules. Its export produces a standard XML file with posts, pages, comments, categories and tags, but only links to your media, which you download separately from the media library. Squarespace Squarespace is the closest rival to Wix for design-led sites. It showed me pounds: Basic £12 a month yearly (£16 monthly), Core £17 (£24), Plus £29 (£36) and Advanced £79 (£89), with a 14-day free trial. Core and above add custom CSS and JavaScript. Fees apply on commerce: 2% on online-store sales at Basic and a 7% fee on digital content and memberships at Basic, falling to 0% at Advanced. Like Wix it's a closed platform, and its export is an XML file that covers one blog page with its posts and layout pages (plus gallery pages on 7.0 sites), but not store pages, product blocks, drafts or custom CSS. Webflow Webflow is the designer's tool with a real CMS behind it. As of 2026-10-09 it lists a free Starter plan (webflow.io domain, 50 CMS items), Basic at $15 a month billed yearly (no CMS), and Premium at $25 a month billed yearly with 20,000 CMS items and 40 collections. Prices are per site, in USD, plus tax. Its collections are the draw, though Wix's CMS has reference fields too, so the difference is mostly layout control rather than data modeling. The catch, in my experience: it has a steeper learning curve than Wix, Premium bandwidth comes in tiers from 50 GB up to 2.5 TB, and its code export is HTML and CSS only. The pricing page says dynamic content (CMS items and pages) can't be exported. Ghost Ghost is a publishing platform, not a site builder. Ghost(Pro) is $18 a month billed yearly for Starter (1,000 members, one staff user), $29 for Publisher (three staff users, custom themes, paid subscriptions, 0% Ghost transaction fee) and $199 for Business (15 staff users), all USD. It's MIT-licensed on GitHub, so you can self-host, and Ghost's hosting docs say self-hosters take on weekly updates and server maintenance. (The hosting docs say Ghost(Pro) is "from $15" a month, which I couldn't reconcile with the pricing page's $18, so I use the pricing page and you should check at checkout.) It's superb at writing, newsletters and memberships. It's not the tool for a bookings page or a general-purpose brochure site. VeloCMS VeloCMS is a hosted blog-first platform that also does newsletters, memberships and a small store. Its pricing page (accessed 2026-10-09) lists Pro at $9 a month, Business at $29 and Agency at $69, with yearly billing taking 20% off. Pro includes a custom domain, a block-based page builder, newsletter, membership paywall, ecommerce, 5 GB of storage and 5,000 subscribers; payments go through your own payment-provider account, so VeloCMS takes no platform fee, although the provider's processing fees still apply. Every plan is paid; the way to try it is a 14-day trial, with a card taken at signup and no charge until day 15. The trade-offs are real: it's young, the marketplace page advertises 57 free integrations (all first-party) and the themes page 40 themes, it's managed SaaS only with no self-hosted edition, and the developer page says third-party plugin development isn't open yet. Content exports as JSON or CSV. How do you check what you can get out of Wix, or move off it? Do this before you build anything big, and again before you leave. 1. Write down your live URLs. Pull the sitemap and note every blog post address exactly as it appears on the live site, because your new platform's redirect map starts from that list. 2. Export every collection to CSV, including hidden and system fields, and check how images, rich text and reference fields look. 3. Get the blog posts out by the least bad route. With no post export, your options are scraping the live pages, the 20-post RSS feed, or an API key plus the Posts API. I haven't run the API route on a large blog, so spot-check rich content for fidelity. 4. Download media you uploaded, folder by folder. Free Wix and Shutterstock images aren't downloadable, so plan replacements. 5. Export your redirect list, if you have one. 6. Plan the cutover. A redirect set up in Wix can only answer requests that reach Wix, so once DNS moves, the 301s must live on the new host. Keep the Wix plan alive until the new site is verified. 7. Accept what stays behind: the design, forms, bookings, member areas, app data and any Velo code that depends on Wix APIs. You'll rebuild those. If you're heading to VeloCMS, its Wix import guide says it reads your public site, so no login or API key is needed. It brings posts from the blog feed, pages found via the sitemap, store products and images (re-hosted), up to 30 pages per run, arriving as drafts. Content behind a login, dynamic pages, and Wix-specific blocks such as forms, bookings and members areas can't be read that way, your design doesn't transfer, and the guide doesn't mention redirects or custom slugs. Other targets have their own import paths. Check the target's guide for the same list of what is skipped. Who is each option best for? - Wix: owners who want to design visually and run a blog, bookings or a small shop on one subscription, and don't plan to move later. - WordPress.org: people who need a particular plugin, want full ownership and accept the maintenance. - WordPress.com: people who like WordPress and want the hosting handled, and can live with the platform's plan structure. - Squarespace: design-led small businesses that want a polished site and a simple editor, and are comfortable with a closed platform. - Webflow: designers who want structured CMS collections with precise layout control, and will learn the tool. - Ghost: writers and publishers whose business is the newsletter and paid membership, with no need for a store or a page builder. - VeloCMS: solo creators and small teams who want a blog, newsletter, membership and small store in one hosted login, and don't need a big plugin ecosystem. When is VeloCMS the wrong choice? Sometimes. If you want a third-party app market, Wix has one and VeloCMS doesn't yet, since its integrations are all first-party. (It lists a booking integration, but I haven't compared it feature by feature with Wix Bookings.) If you want self-hosting or source access, it's out: the application is proprietary. If you want a permanent no-cost tier to test with, it has none, only the card-backed trial. If your site depends on a specific WordPress plugin, it won't run there. And if you want content as Markdown files in Git, that's a different category of tool. FAQ Is Wix a CMS or a website builder? Both. Wix is a hosted website builder, and the Wix CMS is a feature inside it that stores structured content in collections and drives dynamic pages. The docs still say "CMS (formerly Content Manager)" in many places, which is the old name. Is the Wix CMS free? Wix has a free plan, and the free-site limits let you try most data and coding features, with a cap of 1,000 items on a regular free site. Collections grow with the plan: Light 1,500 items, Core 4,000, Business 20,000. Can I export my Wix blog? Wix's help center says exporting blog posts to other platforms isn't currently possible. You can export CMS collections as CSV, pull the latest 20 posts through RSS, or read posts through the Posts API with an API key. Download your media separately. Can I use Wix with my own frontend? Yes, through Wix Headless. Either Wix hosts your frontend (its recommended path, with automatic authentication if you use Astro) or you host it yourself and call Wix over the JavaScript SDK or REST API. Your content and business data still live in Wix. <!-- fact-check 2026-10-09 (independent pass; every figure re-read from the vendor's own page; all 46 links return 200) CORRECTIONS (old -> new, source): 1. "298 pages quota ... without saying how that interacts with per-item URLs, so test thousands of pages first" -> the CMS overview does say it: each dynamic page counts as one page, but item URLs consume no additional pages. support.wix.com 2. "Stores, Bookings and Events write their data to collections" -> "Stores and Bookings" (Events is not named on the page). support.wix.com 3. "Wix Studio is pitched at designers and developers" -> "agencies and professionals building client sites" (Wix: Studio is for Partners and agencies). support.wix.com 4. "a query with two includes costs three" -> "each include() counts toward the request quota (its own example query counts as 3 reads)"; the page's code sample is not rendered, so the two-includes arithmetic could not be confirmed. dev.wix.com 5. "Paid plans raise these, but I only found hard numbers for the free tier" -> confirmed and sharpened: neither the data-limits nor the compute-limits page prints per-plan request numbers (item #2 of the brief resolved: item caps per plan are published, per-plan request-rate caps are not). dev.wix.com 6. "Astro (the recommended framework)" -> Wix recommends the Wix-managed path; Astro adds automatic authentication, SEO support and extensions; any other setup handles auth manually. Also fixed in the FAQ. dev.wix.com and dev.wix.com 7. "Posts API calls authenticated by an API key or OAuth" -> not stated on the Posts API pages; replaced with what is stated: ListPosts returns up to 100 published posts per request, needs the Read Blog scope; API keys are created in the API Keys Manager. dev.wix.com 8. "counts against Wix quotas" (headless) -> "Wix's data quotas apply to anything making data requests". dev.wix.com 9. Sandbox "has to be synced deliberately" now sourced; added 10-second CMS timeout on Business Elite/Elite and "a sync can't be undone". support.wix.com 10. Wix tax note "my fetches disagreed" -> verified on one page: cards say "Price includes GST" while the footnote says prices exclude applicable taxes. Prices on wix.com/plans today: Light $17.77, Core $29.77, Business $39.77, Business Elite $159.77 (yearly, paid in full); the Wix blog lists $17/$29/$39/$159. Both kept, labelled. wix.com , wix.com 11. "most yearly plans include a one-year free domain voucher (first purchase only, new Wix domains only)" -> "selected yearly plans ... first purchase of a site plan only, usable on a new domain or a domain transfer" (the voucher can be redeemed on a transfer). support.wix.com 12. Wix Studio pricing: "Studio plans page didn't render prices" -> still true; additionally Wix's own blog posts conflict (Basic $12 vs $19), so no Studio price is quoted. wix.com , wix.com 13. Webflow: "Typed collections with reference fields make it more database-like than Wix" removed (Wix CMS has reference and multi-reference fields); "bandwidth is metered on a ladder of add-ons" -> Premium bandwidth tiers 50 GB to 2.5 TB; "dynamic CMS items and pages can't be exported" -> "dynamic content (CMS items and pages)". webflow.com , support.wix.com 14. Squarespace export: "layout pages and galleries" -> "layout pages (plus gallery pages on 7.0 sites)". support.squarespace.com 15. WordPress.com export: added that the media library is downloadable separately. wordpress.com 16. Ghost: $15 vs $18 resolved as far as possible: ghost.org/pricing shows Starter $18/mo billed yearly; docs.ghost.org/hosting still says Ghost(Pro) "from $15". Article uses $18 and states the mismatch. ghost.org , docs.ghost.org 17. VeloCMS "There is no free plan, only a 14-day trial..." -> "Every plan is paid; the way to try it is a 14-day trial, with a card taken at signup and no charge until day 15" (founder wording rule; card requirement matches the live pricing FAQ: "We collect your card at signup ... not charged until day 15"). velocms.org 18. VeloCMS "57 integrations" / "40 themes" -> attributed to the live pages ("57 free integrations" on /marketplace, "40 themes" on /themes, read 2026-10-09). The repo project map counts 58 built-in plugins and 62 themes, which is internal inventory, not what the public pages show. 19. VeloCMS wrong-choice paragraph: "If you need bookings or a bigger app market, Wix is the better product" removed (VeloCMS lists a booking integration; "better" was unsourced) -> third-party app market (Wix has one, VeloCMS has none yet); "typed content models in git, Webflow's reference fields" removed (Webflow is not Git-based and the VeloCMS content-model claim was unverifiable) -> Markdown-in-Git is a different category; "if you want a free tier ... it has none" -> "permanent no-cost tier". 20. "Who is it for / when to pick" intro: "large or relational content model" -> "content runs into the item caps" (Wix supports reference fields). 21. W3Techs: 4.2% Wix / 40.1% WordPress / 5.4% Shopify confirmed on the page today (heading "October 2026", updated daily); wording now says "October 2026 report". w3techs.com 22. corewebvitals.io: Q2 2026 phone data confirmed (Wix 94.4% / 1.6 s, WordPress 69.5% / 2.7 s, Squarespace 85.8% / 2.2 s, Webflow 15 sites and Ghost 37 sites at 100%). Unchanged. corewebvitals.io 23. Stray comma fixed in the CMS-timeout sentence; the numbered Sources list was removed because every entry is already linked inline (word-count budget; Posts API link added inline). VERIFIED UNCHANGED: collection item caps per plan (Light 1,500 / Core 4,000 / Business 20,000 / Elite 10,000,000; free 1,000, Studio-account free 10,000), 10 GB / 100 GB database storage, 512 KB per item, 298-page quota (100 static), free-site compute and data table, Velo/Dev Mode/Studio coding/IDE/Git integration claims, Wix Blog caps (100,000 posts, 10 categories), export article quote, blog-export "currently not possible", RSS 20 posts, CSV export options (UTF-8, UTC+0), media download limits, redirects 500 per CSV, WordPress importer (5,000 per run; skips tags, authors, comments, forms, plugins, code, documents), SEO features, Studio plan allowances (1,500 / 4,000 / 20,000 / 10,000,000 items; 3 / 5 / 10 / 100 client accounts), WordPress.org GPLv2+ PHP/MariaDB, WordPress.com prices, Squarespace GBP prices and fees, Webflow Basic $15 / Premium $25 / 20,000 items / 40 collections, Ghost tiers and fees, VeloCMS plan prices/limits/20% annual toggle/0% platform fee/proprietary/no self-host/third-party plugins not open/JSON+CSV export, VeloCMS Wix import guide (public-site read, 30 pages per run, drafts, exclusions). NOT VERIFIABLE ON AN OFFICIAL SOURCE (already labelled as such in the text): how Wix CSV export writes media and reference fields; Posts API blog export fidelity (author's untested route); Wix Studio prices; per-plan Wix data-request rates. -->
GoodSender vs Ghost vs Substack vs beehiiv: an honest comparison If you want to earn from your writing or grow an audience inside a platform's network, pick Ghost, Substack or beehiiv. GoodSender is built for a different job: a blog on your own domain with free, reliable email to your subscribers. You write in the editor yourself, or let Claude or Codex help. Below is what the four have in common, what GoodSender adds, and where the others go further. Everything is based on each vendor's own pricing pages and docs, checked October 8, 2026. What all four have in common | | GoodSender | Ghost | Substack | beehiiv | |---|---|---|---|---| | Free way to start | Free on your own domain | Free if you self-host; Ghost(Pro) from $18/month | Free | Free plan, up to 2,500 subscribers | | Your own domain | Yes, required to go public | Free custom domain | One time $50 | Paid plans | | Site and search basics | Sitemap, RSS, canonical tags, no setup | Advanced SEO tools, RSS | SEO settings | Website builder on every plan | | Email to subscribers | Yes | Yes | Yes | Yes | The basics are covered everywhere: a site for your posts, your own domain, and email to subscribers. The differences are in cost, workflow and growth tools. What GoodSender adds | | GoodSender | Ghost | Substack | beehiiv | |---|---|---|---|---| | Email pricing | Free up to 100,000 emails a month, priced on emails sent | Rises with members: $105/month at 10,000 (Publisher) | Free; 10% of paid subscriptions | Rises with subscribers: $139/month at 10,000 (Lite) | | Work with Claude or Codex | Official MCP and CLI: draft, preview, publish | No official MCP server | Official API is read only | Official MCP writes drafts; you publish in the app | | Share on publish | LinkedIn, X, Bluesky, Mastodon, dev.to, Hashnode | Social web distribution | Video to YouTube and LinkedIn | Not listed | Email that stays free as you grow. A weekly post to 10,000 subscribers is about 43,000 emails a month: free on GoodSender, and $10 a month once you pass 100,000 emails. Every subscriber confirms by double opt-in, inactive readers are asked to reconfirm after 120 days, and unsubscribes and complaints are suppressed automatically. Sending runs on Laneful, built by the team behind SendGrid. AI help when you want it. Write everything yourself, or ask Claude or Codex to help with a draft, a preview or the publish step. beehiiv's MCP stops at drafts that "must be published from the app". Ghost has no official MCP server, and Substack's API only reads public data. One publish, many places. A post can be announced on four social networks and republished on dev.to and Hashnode with a canonical link back to your blog. Pages need no search setup. Where Ghost, Substack and beehiiv go further | | GoodSender | Ghost | Substack | beehiiv | |---|---|---|---|---| | Paid subscriptions | No | Yes, 0% Ghost fee | Yes, Substack keeps 10% | Yes, 0% take rate | | Discovery network | No | Recommendations, Explore network, referrals | Recommendations, Notes, app, referrals | Recommendation network, ad network | | Comments and community | No | Comments | Comments, chat, Notes | Not listed | | Themes and design | GoodSender templates; AI design generation in development | Marketplace and custom themes (Publisher and up) | Not listed | Website builder | | Import existing posts | Subscribers only | Not listed | Posts and subscribers from 12+ platforms | Not listed | Sources: Ghost pricing, Ghost on GitHub; Substack features, custom domain, SEO, API terms; beehiiv pricing, beehiiv MCP. "Not listed" means we did not find it on the vendor's pricing or feature pages. Which one should you choose? - Ghost: paid memberships, deep design control or self-hosting. - Substack: readers through its network and app, at 10% of paid subscriptions. - beehiiv: growth and monetization tools such as recommendation and ad networks. - GoodSender: your own domain, free email to your readers, and AI help when you want it. FAQ Is there a free blog platform with email on your own domain? GoodSender: the blog on your own domain is free, and so are up to 100,000 emails a month to your subscribers. Substack is also free to send, with a one time $50 for a custom domain. Which blog platform works best with Claude? Of these four, only GoodSender has an official MCP server that lets Claude help with the whole post, from draft to publish. beehiiv's official MCP creates drafts that you publish in beehiiv. Start your blog on GoodSender if these trade-offs work for you.