A fully open-source, WordPress-compatible CMS built on Astro — admin and public frontend in a single deployment.
No PHP. No legacy baggage. TypeScript, Astro 4 SSR, Drizzle ORM, and your choice of hosting.
| Platform | One-click |
|---|---|
| Cloudflare Pages | |
| Railway | |
| Render | |
| Docker | docker compose up — see Docker below |
AstroPress is a modern CMS that speaks WordPress — same wp_* database schema, same mental model — but runs on the edge with zero PHP. Developers get the extensibility of WordPress; users get a fast, cheap, globally-distributed site.
Key features:
- WordPress-compatible
wp_*schema (Drizzle ORM + SQLite/D1) - Visual block-based page and theme editor (full-screen ThemeEditor)
- Gutenberg block editor for posts and classic content
- Custom post types, taxonomies, custom fields (ACF-style) — all managed via UI
- WPForms-style form builder with entries, conditional logic, multi-page
- Navigation menus with drag-and-drop reorder and submenu nesting
- Plugin system — drop a package in
/plugins, register inapps/admin/src/plugins.ts - Single installation — admin (
/admin/*) and public frontend (/*) in one app - Cloudflare-native: D1 database + R2 object storage + Pages hosting
- Session-based auth (Lucia v3)
AstroPress runs as a single Astro SSR app that serves both the admin dashboard and the public-facing website:
/ → public homepage (blog list or static front page)
/blog/[slug] → blog post
/[slug] → page (supports visual block editor)
/forms/[id] → standalone form page
/admin/* → CMS dashboard (auth required)
/api/* → REST API (most endpoints require auth)
Everything shares one database. No separate frontend deployment needed.
- Node.js 20+
- pnpm 9+
git clone https://github.com/awsmin/AstroPress
cd astropress
pnpm installpnpm db:setup # runs migrations against local.db
pnpm db:seed # optional: seeds demo contentpnpm dev| URL | Description |
|---|---|
| http://localhost:4321 | Admin + public site |
| http://localhost:4321/admin | CMS dashboard |
Visit http://localhost:4321 — the setup wizard runs on first boot to create your admin account.
No CLI or terminal needed. After clicking the button:
- Authorize GitHub + Cloudflare when prompted
- Cloudflare forks the repo and creates a Pages project automatically
- In the Cloudflare dashboard, configure the build settings:
- Root directory:
apps/admin - Build command:
ASTRO_ADAPTER=cloudflare pnpm build - Build output directory:
dist - Deploy command: (leave empty)
- Root directory:
- Go to Settings → Functions → D1 database bindings:
- Click Add binding → Variable name:
DB→ Create a new D1 database namedastropress
- Click Add binding → Variable name:
- Save and trigger a new deployment
That's it. On first visit, AstroPress automatically creates all database tables and redirects you to the setup wizard to create your admin account.
cd apps/admin
npx wrangler d1 create astropress
npx wrangler r2 bucket create astropress-media
npx wrangler pages project create astropress
# Build and deploy
ASTRO_ADAPTER=cloudflare pnpm build
npx wrangler pages deploy distOr manually:
- Push the repo to GitHub
- Create a new Railway project → Deploy from GitHub repo
- Set environment variables in the Railway dashboard:
DATABASE_URL— e.g.file:./data/astropress.db(Railway persistent volume) or a PostgreSQL URLAUTH_SECRET— a random 32+ character string
Railway auto-detects railway.toml and builds with Docker.
Render reads render.yaml automatically. Set DATABASE_URL in the Render dashboard after the first deploy. A 1 GB persistent disk is attached at /app/data for the SQLite database.
cp .env.example .env # edit AUTH_SECRET
docker compose upOpen http://localhost:4321 — database tables are created automatically on first boot, then the setup wizard runs to create your admin account.
docker build -t astropress .
docker run -p 4321:4321 \
-e DATABASE_URL=file:./data/astropress.db \
-e AUTH_SECRET=your-secret-here \
-v $(pwd)/data:/app/data \
astropress| Variable | Description |
|---|---|
DATABASE_URL |
SQLite: file:./data/astropress.db · PostgreSQL: postgres://... |
AUTH_SECRET |
32+ char string for session signing |
On Cloudflare Pages, the D1 database is bound automatically via wrangler.toml — no DATABASE_URL needed.
astropress/
├── apps/
│ └── admin/ # Single Astro SSR app (admin + public frontend)
│ └── src/
│ ├── components/ # BlockRenderer.astro
│ ├── islands/ # React islands (BlockEditor, FormBuilder, ThemeEditor …)
│ ├── layouts/ # AdminLayout.astro, BaseLayout.astro
│ ├── lib/ # icons.ts, posts.ts, public-query.ts, formRenderer.ts …
│ ├── middleware.ts
│ ├── pages/
│ │ ├── index.astro # public homepage
│ │ ├── [slug].astro # public pages
│ │ ├── blog/[slug].astro # blog posts
│ │ ├── forms/[id].astro # standalone form page
│ │ ├── admin/ # CMS dashboard pages
│ │ └── api/ # REST endpoints
│ └── plugins.ts
├── packages/
│ ├── core/ # Drizzle schema, registry, query helpers, types
│ ├── auth/ # Lucia v3 session auth
│ ├── api/ # Hono router foundation
│ └── ui/ # Shared React components
├── plugins/
│ └── seo/ # First-party SEO plugin
├── themes/
│ └── default/ # Default front-end theme styles
├── Dockerfile
├── docker-compose.yml
├── railway.toml
├── render.yaml
└── wrangler.toml
Import from @astropress/core/query in any Astro page:
---
import { queryPosts, getField, getPostTerms, getSiteInfo } from "@astropress/core/query";
const db = Astro.locals.db;
// Like WP_Query
const { posts, total, pages } = await queryPosts(db, {
type: "book",
perPage: 12,
orderBy: "title",
order: "asc",
});
// Like ACF get_field / get_post_meta
const price = await getField(db, post.id, "price");
// Like get_the_terms
const categories = await getPostTerms(db, post.id, "category");
// Like get_bloginfo
const site = await getSiteInfo(db);
---| Function | WP equivalent |
|---|---|
queryPosts(db, args) |
WP_Query |
getPost(db, idOrSlug, type?) |
get_post() |
getPostById(db, id) |
get_post() |
getPostBySlug(db, slug, type?) |
get_page_by_path() |
getField(db, postId, key) |
ACF get_field() |
getFields(db, postId) |
all meta as Record<string,string> |
getTerms(db, taxonomy, args?) |
get_terms() |
getPostTerms(db, postId, taxonomy) |
get_the_terms() |
getOption(db, name, fallback?) |
get_option() |
getSiteInfo(db) |
get_bloginfo() |
- Create a package in
/plugins/my-plugin/ - Export a plugin config:
// plugins/my-plugin/src/index.ts
import { definePlugin, registerPostType } from "@astropress/core";
export default definePlugin({
name: "My Plugin",
version: "1.0.0",
register() {
registerPostType("product", {
label: "Product",
pluralLabel: "Products",
icon: "bag",
public: true,
showInMenu: true,
supports: ["title", "editor", "thumbnail", "custom-fields"],
});
},
});- Load it in
apps/admin/src/plugins.ts:
import myPlugin from "@astropress/my-plugin";
loadPlugin(myPlugin);See CONTRIBUTING.md and docs/.
MIT — see LICENSE.