Inkwell is a full-featured, full-stack blogging platform where readers explore published stories and admins write rich, beautifully formatted posts. It ships with Google authentication, a WYSIWYG rich-text editor, likes, comments, view tracking, image uploads, and a role-based admin dashboard β all wrapped in a warm, editorial "inkwell" design system with dark/light themes.
- π Live demo: https://inkwell-blogs.pages.dev/
- π§βπ» Repository: https://github.com/Anos714/Inkwell
- Overview
- Features
- Tech Stack
- Architecture
- Project Structure
- Prerequisites
- Getting Started
- Environment Variables
- Available Scripts
- API Reference
- Database Schema
- Deployment
- Roadmap
- Contributing
- License
Inkwell is a monorepo with two independently deployable applications:
| App | Stack | Port (dev) | Description |
|---|---|---|---|
| frontend | React 19, Vite, TailwindCSS v4, TypeScript | 5173 |
Public blog reader + authenticated admin workspace |
| backend | Bun, Hono, Drizzle ORM, TypeScript | 8000 |
REST API (/api/v1), auth, content, uploads |
The backend talks to a Neon Postgres database and a Redis instance (used as a
refresh-token store), uses Cloudinary for image hosting, and Google OAuth 2.0
as the sole identity provider. The frontend is a single-page app that communicates
with the API using fetch with credentials: 'include' (cookie-based refresh).
- π° Blog listing with debounced search (300 ms) and pagination
- π Article pages rendered from sanitized HTML with a reading-friendly layout
- β€οΈ Like / unlike posts with optimistic UI updates and rollback on failure
- π¬ Comments β post your own (2000-char limit) and delete ones you own
- π View counts β tracked once per visit per article
- π Share menu β copy link, share to WhatsApp or X/Twitter
- π Dark / light theme, persisted to
localStoragewith no flash of unstyled content - π¨ Polished skeleton shimmer loaders, empty states, and error states everywhere
- π Google sign-in (OAuth 2.0 authorization-code flow with a
stateparam to prevent CSRF) - π Admin dashboard β aggregate stats: total/published/draft posts, views, likes, comments
- βοΈ Rich-text editor (TipTap) with headings, lists, task lists, quotes, code blocks, text alignment, colors, highlighting, links, and images
- πΌοΈ Cover image & avatar uploads β signed by the backend, uploaded directly to Cloudinary
- π·οΈ Tags, slugs, drafts, and publishing toggle for every post
- ποΈ Content manager β list, edit, and delete any post with confirmation dialogs
- π€ Profile page β update your username and avatar, or delete your account
- π‘οΈ Role-based access β admin-only routes redirect unauthorized users automatically
- Layered backend β route β controller β service β repository, one module per feature
- JWT auth with Redis-backed refresh rotation β 15-minute access tokens plus 7-day
refresh tokens stored only as a SHA-256 hash in httpOnly,
sameSite,securecookies - Zod everywhere β request validation on the server and typed/validated API responses on the client
- Centralized error handling β operational errors, Zod issues (422), Postgres unique violations (409), and malformed JSON (400) all map to consistent JSON payloads
- Fail-fast environment validation β the server refuses to boot on missing config
- Security touches β DOMPurify sanitization of stored HTML, base64 images disabled in the
editor,
rel="noreferrer"on outbound share links, and strict CORS with credentials - Performance β route-level code splitting (
React.lazy), React Compiler, direct-to-CDN uploads so the backend never touches binary data - Accessibility β ARIA labels,
role="alert"/role="alertdialog"modals, keyboard (Escape) + outside-click dismissal, and semantic markup - SEO β Open Graph and Twitter card meta tags plus a 1200Γ630 social preview image
Frontend
- React 19 + Vite 8
- TypeScript (strict mode)
- TailwindCSS v4 (CSS-first config, custom
@themetokens) - React Router v8
- TanStack React Query v5 (server state)
- Zustand v5 (auth store, persisted)
- TipTap v3 (rich-text editor)
- Motion (animations)
- DOMPurify (HTML sanitization)
- Zod v4 (schema validation)
Backend
- Bun (runtime & bundler)
- Hono v4 (web framework)
- TypeScript (strict mode)
- Drizzle ORM + Drizzle Kit (migrations)
- Zod v4 + @hono/zod-validator
- hono/jwt (JWT, HS256)
Data & services
- Neon β serverless PostgreSQL
- Redis β refresh-token store (via
ioredis) - Cloudinary β image hosting with signed uploads
- Google Cloud β OAuth 2.0 identity (
google-auth-library)
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Browser (SPA) β
β React 19 + React Router + React Query + Zustand + TailwindCSS v4 β
βββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββ
β fetch, credentials: 'include'
β Bearer <access-token>
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Backend (Bun + Hono) β
β β
β Middleware: cors β errorHandler (global) β requireAuth (per route) β
β Modules: users Β· blogs Β· blog-likes Β· blog-comments Β· uploads β
β Layers: route β controller β service β repository β drizzle β
ββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββββββββ
β β β
βΌ βΌ βΌ
βββββββββββ ββββββββββββββββ βββββββββββββββββ
β Neon β β Redis β β Cloudinary β
β Postgresβ β refresh-tokenβ β (images) β
β β β hashes β β β
βββββββββββ ββββββββββββββββ βββββββββββββββββ
β²
β Google OAuth 2.0 (IdP)
ββββββββββββββββββββββ
β Google Cloud β
β Identity Platform β
ββββββββββββββββββββββ
Request lifecycle (example: creating a post)
POST /api/v1/blogshitsblogs.route.tsrequireAuthverifies the Bearer JWT and loads{ id, role }onto the Hono contextzValidatorrunscreateBlogSchema(strict) against the JSON bodyblogs.controllerslugifies the title and delegates toblogs.serviceblogs.serviceenforces the admin-only check, then callsblogs.repositoryblogs.repositoryinserts the row via Drizzle into Neon Postgres- Any thrown
AppError/ZodErroris normalized by the globalerrorHandler
Auth flow
- The client redirects to Google with a random
statevalue stored insessionStorage - Google redirects back with an authorization
code - The client sends
{ code }toPOST /api/v1/users/auth/google - The server exchanges the code, verifies the ID token, and find-or-creates the user
- An access token (15 min) is returned in the body; a refresh token (7 d) is set as an httpOnly cookie, and only its SHA-256 hash is stored in Redis
- On load, the client silently refreshes via
POST /api/v1/users/refresh(cookie)
Inkwell/
βββ backend/
β βββ migrations/ # Drizzle Kit SQL migrations
β βββ src/
β β βββ config/ # env (Zod), cors, redis, google, cloudinary
β β βββ db/ # drizzle client + schema.ts (tables/relations)
β β βββ middleware/ # requireAuth, global errorHandler
β β βββ modules/
β β β βββ users/ # auth, profile, refresh, logout
β β β βββ blogs/ # CRUD, dashboard, admin listing
β β β βββ blog_likes/ # like toggle + status
β β β βββ blog-comments/ # comments + moderation
β β β βββ uploads/ # Cloudinary signed upload params
β β βββ types/hono.d.ts # Context augmentation (user: { id, role })
β β βββ utils/ # AppError, JWT sign/verify helpers
β β βββ index.ts # app bootstrap + route mounting
β βββ drizzle.config.ts
β βββ .env.example
β βββ package.json
β
βββ frontend/
β βββ public/ # favicon.svg, og-image.svg, icons sprite
β βββ src/
β β βββ components/ # brand-logo, theme-toggle, theme init
β β βββ features/
β β β βββ auth/ # api, components, useAuth hook, store, schemas
β β β βββ blogs/ # api, components (home, list, detail,
β β β # editor, admin pages), types
β β βββ lib/api.ts # fetch client with Zod-validated responses
β β βββ styles/globals.css # Tailwind v4 @theme tokens + custom CSS
β β βββ App.tsx # BrowserRouter + lazy routes
β β βββ main.tsx # QueryClientProvider + theme bootstrap
β βββ index.html # SEO meta, OG/Twitter tags
β βββ vite.config.ts # tailwindcss + react + babel(reactCompiler)
β βββ .env.example
β βββ package.json
β
βββ README.md
The frontend uses a feature-sliced layout: each feature (auth, blogs) is
self-contained with its own api/, components/, hooks/, store/, and schemas.
Shared, app-wide code lives in lib/, components/, and styles/.
Make sure the following are installed and set up before you begin:
| Requirement | Version / Notes |
|---|---|
| Node.js | β₯ 20 (for tooling) |
| Bun | β₯ 1.4 (backend runtime & package manager) |
| A code editor | VS Code recommended |
| A terminal | with git, curl |
You will also need free-tier accounts for the external services:
- Neon β create a project and copy the
DATABASE_URL - Redis β any instance (e.g. Upstash); copy the
REDIS_URL - Cloudinary β copy your cloud name, API key, and API secret
- Google Cloud Console β create an OAuth 2.0
Web application client ID to get
GOOGLE_CLIENT_ID,GOOGLE_CLIENT_SECRET, and set an authorized redirect URI
git clone https://github.com/Anos714/Inkwell.git
cd InkwellCreate a Neon Postgres project and a Redis instance, then keep both connection strings
handy β you'll add them to the backend .env in step 4.
Apply the existing migrations (recommended) or generate fresh ones from the schema:
cd backend
bun install
bun run db:migrate # apply existing migrations in ./migrations
# or
bun run db:generate # generate a new migration from src/db/schema.tsTip β promoting a user to admin: authentication is Google-only, and roles default to
user. To unlock the admin workspace, promote your account in Postgres:UPDATE users SET role = 'admin' WHERE email = 'you@example.com';
Google OAuth (Cloud Console)
- Go to APIs & Services β Credentials β Create credentials β OAuth client ID
- Choose Web application
- Under Authorized redirect URIs, add:
http://localhost:5173/api/auth/google/callback(development)- your production callback URL (e.g.
https://inkwell-0tx.pages.dev/api/auth/google/callback)
- Copy the Client ID and Client secret
Cloudinary
- Create an account (or use the programmable media demo environment)
- Copy your Cloud name, API Key, and API Secret from the dashboard
cd backend
cp .env.example .env # then fill in the values (see below)
bun install
bun run dev # watch mode on http://localhost:8000Verify it is alive:
curl http://localhost:8000/ping
# { "success": true, "message": "pong" }Fill backend/.env with the values from Environment Variables.
If any required variable is missing, the server prints a structured error and exits.
cd frontend
cp .env.example .env # then fill in the values (see below)
bun install
bun run dev # Vite dev server on http://localhost:5173Open http://localhost:5173, sign in with Google, and start exploring. π
The committed
backend/.env.exampleis intentionally a stub β copy it and fill in every value below. All variables are validated with Zod at startup; the process exits with a clear error tree if any are missing or malformed.
| Variable | Required | Example / Default | Purpose |
|---|---|---|---|
PORT |
no | 8000 |
Server port |
BUN_ENV |
no | development |
development | production | test (CORS/cookies) |
DATABASE_URL |
yes | postgresql://user:pass@host/db?sslmode=require |
Neon Postgres connection string |
REDIS_URL |
yes | red://default:pass@host:6379 |
Redis (refresh-token store) |
ACCESS_TOKEN_SECRET_KEY |
yes | random 32+ char string | JWT access-token signing secret (15 min) |
REFRESH_TOKEN_SECRET_KEY |
yes | random 32+ char string | JWT refresh-token signing secret (7 days) |
FRONTEND_URL |
yes | http://localhost:5173 |
Allowed CORS origin in production |
GOOGLE_CLIENT_ID |
yes | xxxx.apps.googleusercontent.com |
Google OAuth client ID + IdToken audience |
GOOGLE_CLIENT_SECRET |
yes | GOCSPX-xxxx |
Google OAuth client secret |
GOOGLE_REDIRECT_URI |
yes | http://localhost:5173/api/auth/google/callback |
Must match the Google Console redirect URI |
CLOUDINARY_CLOUD_NAME |
yes | your-cloud |
Cloudinary cloud name |
CLOUDINARY_API_KEY |
yes | 123456789012345 |
Cloudinary API key (sent to client for uploads) |
CLOUDINARY_API_SECRET |
yes | xxxx |
Cloudinary secret (server-side signing only) |
Generate strong secrets with:
openssl rand -base64 48| Variable | Required | Example / Default | Purpose |
|---|---|---|---|
VITE_API_URL |
yes | http://localhost:8000 |
Base URL of the backend API |
VITE_GOOGLE_CLIENT_ID |
yes | xxxx.apps.googleusercontent.com |
Google OAuth client ID |
VITE_GOOGLE_REDIRECT_URI |
no | ${window.location.origin}/api/auth/google/callback |
OAuth redirect URI (fallback used if unset) |
| Script | Command | Description |
|---|---|---|
bun run dev |
bun --watch |
Start the dev server with hot reloading |
bun run start |
β | Start the production server |
bun run build |
β | Typecheck + bundle to ./dist |
bun run typecheck |
tsc --noEmit |
Strict type checking |
bun run lint |
eslint |
Lint src/**/*.ts |
bun run db:generate |
drizzle-kit | Generate a migration from src/db/schema.ts |
bun run db:migrate |
drizzle-kit | Apply pending migrations |
| Script | Command | Description |
|---|---|---|
bun run dev |
vite |
Start the Vite dev server |
bun run build |
tsc -b && vite build |
Typecheck + production build to ./dist |
bun run preview |
vite preview |
Preview the production build locally |
bun run lint |
eslint . |
Lint the codebase |
Base URL: http://localhost:8000 Β· API prefix: /api/v1 Β· Auth: Authorization: Bearer <token>
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/ping |
β | Health check β { message: "pong" } |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/users/auth/google |
β | Google OAuth login/signup (accepts { code }), issues tokens |
POST |
/users/refresh |
β | Exchange the refresh cookie for a new access token |
GET |
/users/me |
β | Current authenticated user |
PATCH |
/users/me |
β | Update profile (username, uniqueness-checked) |
PATCH |
/users/me/avatar |
β | Set avatar URL |
DELETE |
/users/me |
β | Delete own account (cascades to likes & comments) |
POST |
/users/logout |
β | Revoke refresh token & clear cookie |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/blogs |
β | List published blogs (?page&limit&search) |
GET |
/blogs/:slug |
β | Get a published blog by slug (+ likes count) |
POST |
/blogs/:slug/views |
β | Increment the view counter (published only, atomic) |
POST |
/blogs |
β admin | Create a blog (slug auto-generated) |
PATCH |
/blogs/:blogId |
β admin | Update a blog (any subset of fields) |
DELETE |
/blogs/:blogId |
β admin | Delete a blog |
GET |
/blogs/dashboard |
β admin | Aggregate stats (blogs, published, drafts, views, likes, comments) |
GET |
/blogs/admin |
β admin | List all blogs including drafts |
GET |
/blogs/admin/:slug |
β admin | Get any blog by slug including drafts |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/blog-likes/:blogId/like |
β | Toggle like β 201 liked / 200 unliked + totalLikes |
GET |
/blog-likes/:id/like-status |
β | Current user's like status + total likes |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
POST |
/blog-comments/:blogId/comments |
β | Create a comment |
GET |
/blog-comments/:blogId/comments |
β | List comments (with author username & avatar) |
DELETE |
/blog-comments/comments/:commentId |
β | Delete a comment (owner or admin) |
| Method | Endpoint | Auth | Description |
|---|---|---|---|
GET |
/uploads/signature?type= |
β | Cloudinary signed upload params. type: blogCover (5 MB, admin) or avatar (2 MB) |
Error format β all errors return a consistent JSON payload:
{
"success": false,
"message": "Validation failed",
"issues": [{ "path": ["title"], "message": "Title is required" }]
}Common status codes: 400 bad request, 401 unauthorized, 403 forbidden (admin-only),
404 not found, 409 conflict (unique violation), 422 validation error, 500 server error.
PostgreSQL (Neon), managed with Drizzle ORM. All IDs are uuid (uuidv7); all
timestamps are timestamptz. role is a Postgres enum: user | admin.
users
βββ id uuid PK (uuidv7)
βββ username varchar(100) UNIQUE NOT NULL
βββ email varchar(255) UNIQUE NOT NULL
βββ avatar_url text
βββ google_id text UNIQUE NOT NULL
βββ role role enum DEFAULT 'user'
βββ created_at timestamptz NOT NULL
βββ updated_at timestamptz NOT NULL
blogs
βββ id uuid PK (uuidv7)
βββ title varchar(255) NOT NULL
βββ slug varchar(255) UNIQUE NOT NULL
βββ description text
βββ content jsonb NOT NULL -- rich-text editor HTML
βββ cover_image text
βββ tags text[] NOT NULL DEFAULT '{}'
βββ is_published boolean NOT NULL DEFAULT false
βββ published_at timestamptz
βββ views integer NOT NULL DEFAULT 0
βββ created_at timestamptz NOT NULL
βββ updated_at timestamptz NOT NULL
blog_likes -- composite PK, both FKs ON DELETE CASCADE
βββ user_id uuid FK β users.id
βββ blog_id uuid FK β blogs.id
βββ created_at timestamptz NOT NULL
blog_comments
βββ id uuid PK (uuidv7)
βββ blog_id uuid FK β blogs.id ON DELETE CASCADE
βββ user_id uuid FK β users.id ON DELETE CASCADE
βββ content text NOT NULL
βββ created_at timestamptz NOT NULL
βββ updated_at timestamptz NOT NULL
Relations: one user β many likes & comments; one blog β many likes & comments.
The project is designed to deploy the two apps independently.
Frontend (Cloudflare Pages) β the live demo runs at inkwell-blogs.pages.dev.
- Build command:
bun run build - Output directory:
frontend/dist - Environment variables:
VITE_API_URL,VITE_GOOGLE_CLIENT_ID,VITE_GOOGLE_REDIRECT_URI - Add a SPA fallback so client-side routes resolve to
index.html(Pages does this automatically when there is no matching static asset)
Backend β any runtime that supports Bun (or the compiled bundle):
- Build with
bun run build(emitsbackend/dist), or runbun run start - Set all backend environment variables in the host's secret store
- Ensure
FRONTEND_URLpoints to your deployed frontend so CORS and cookies work - Keep the
/api/auth/google/callbackredirect URI consistent between the client, the server, and the Google Cloud Console
Cookies: in production the refresh cookie is
httpOnly,secure, andsameSite=none, so the API must be served over HTTPS for credentials to be sent.
Ideas for future work (not yet implemented):
- Rate limiting / request throttling
- Full-text search (Postgres
tsvector) instead ofilike - Email notifications & newsletter signup
- Draft previews and scheduling
- Reading list / bookmarks
- richer author profiles and bios
- Automated test suite (unit + integration)
- Analytics dashboard with charts
Contributions are welcome! Please follow this workflow:
- Fork the repository
- Create a feature branch:
git checkout -b feat/your-feature - Commit using Conventional Commits
(e.g.
feat(blogs): add bookmarks) β this repo follows that style - Make sure linting and typechecking pass:
cd backend && bun run lint && bun run typecheck cd ../frontend && bun run lint
- Open a Pull Request describing your changes
This project is open source and released under the MIT License β see the
LICENSE file for details. A permissive license means you're free to
learn from, fork, and adapt this project for your own use.
Built with β & Bun + React.
β Star the repo if you like it!