A minimal, secure, file-based CMS for Next.js without database requirements. Store content as markdown files with GitHub integration for production deployments.
- 📝 File-based Storage - Markdown files with frontmatter in
/public/content - 🔐 Secure Authentication - Timing-safe password comparison, rate limiting, input validation
- 🚀 Vercel Compatible - Deploy without any database setup
- 🐙 GitHub Integration - Automatic file commits in production
- 📦 Zero Config - Minimal setup required
- 🎯 TypeScript First - Full type safety with comprehensive type definitions
- ⚡ Lightweight - Small bundle size (~30KB), minimal dependencies
- 🛡️ Security Hardened - Built with security best practices
npm install @fydemy/cms
# or
pnpm add @fydemy/cms
# or
yarn add @fydemy/cmsRun the initialization command in your Next.js App Router project:
npx fydemy-cms initThis command will automatically:
- Create the content directory
- Scaffold Admin UI pages (
/app/admin) - Create API routes (
/app/api/cms) - Create a
.env.local.examplefile - Provide instructions for updating
middleware.ts
Copy .env.local.example to .env.local and set your credentials:
cp .env.local.example .env.localUpdate variables in .env.local:
# Required for authentication
CMS_ADMIN_USERNAME=admin
CMS_ADMIN_PASSWORD=your_secure_password
CMS_SESSION_SECRET=your-secret-key-must-be-at-least-32-characters-long
# Optional: For production (GitHub integration)
GITHUB_TOKEN=ghp_your_github_token
GITHUB_REPO=username/repository
GITHUB_BRANCH=mainSecurity Note: Use strong passwords and keep
CMS_SESSION_SECRETat least 32 characters long.
import { getMarkdownContent } from "@fydemy/cms";
export default async function BlogPost({
params,
}: {
params: { slug: string };
}) {
const post = await getMarkdownContent(`${params.slug}.md`);
return (
<article>
<h1>{post.data.title}</h1>
<p>{post.data.description}</p>
<div>{post.content}</div>
</article>
);
}- Timing-Safe Authentication: Uses
crypto.timingSafeEqualto prevent timing attacks - Rate Limiting: 5 login attempts per 15 minutes per IP address
- Input Validation: All inputs validated and sanitized
- Path Validation: Prevents directory traversal attacks
- File Size Limits: Default 10MB maximum file size
- Secure Sessions: httpOnly, sameSite, and secure cookies in production
- No Username Enumeration: Generic error messages
- Strong Credentials: Use strong, unique passwords for
CMS_ADMIN_PASSWORD - Secret Management: Keep
CMS_SESSION_SECRETat least 32 characters - GitHub Token Security: Use minimal permissions (only
reposcope) - HTTPS Only: Always use HTTPS in production
- Regular Updates: Keep dependencies up to date
- Environment Variables: Never commit
.envfiles
For more security information, see SECURITY.md.
// Read markdown file
const content = await getMarkdownContent("blog/post.md");
// Returns: { data: {...}, content: "..." }
// Write markdown file
await saveMarkdownContent(
"blog/post.md",
{ title: "My Post", date: "2024-01-01" },
"# Hello World"
);
// Delete file
await deleteMarkdownContent("blog/post.md");
// List files
const files = await listMarkdownFiles("blog");
// Returns: ['blog/post1.md', 'blog/post2.md']
// Check if file exists
const exists = await markdownFileExists("blog/post.md");import { parseMarkdown, stringifyMarkdown } from "@fydemy/cms";
// Parse markdown string
const { data, content } = parseMarkdown(rawMarkdown);
// Convert to markdown
const markdown = stringifyMarkdown({ title: "Post" }, "Content here");import { validateCredentials, createSession } from "@fydemy/cms";
// Validate credentials
const isValid = validateCredentials("admin", "password");
// Create session (returns JWT)
const token = await createSession("admin");import {
validateFilePath,
validateUsername,
validatePassword,
sanitizeFrontmatter,
} from "@fydemy/cms";
// Validate file path (prevents directory traversal)
const safePath = validateFilePath("blog/post.md");
// Validate username
validateUsername("admin"); // throws if invalid
// Sanitize frontmatter data
const safe = sanitizeFrontmatter({ title: "Test", script: "<script>" });Files are stored locally in /public/content directory.
When NODE_ENV=production and GITHUB_TOKEN is set, all file operations are performed via GitHub API, creating commits directly to your repository.
| Variable | Required | Description |
|---|---|---|
CMS_ADMIN_USERNAME |
Yes | Admin username |
CMS_ADMIN_PASSWORD |
Yes | Admin password |
CMS_SESSION_SECRET |
Yes | JWT secret (min 32 chars) |
GITHUB_TOKEN |
Production | GitHub personal access token |
GITHUB_REPO |
Production | Repository (format: owner/repo) |
GITHUB_BRANCH |
Production | Branch name (default: main) |
- Create a GitHub Personal Access Token with
repopermissions - Add the token to your environment variables
- Deploy to Vercel and configure the environment variables
Yes! The package includes security hardening, rate limiting, and has been tested for production use. Make sure to follow security best practices.
This package is designed for Next.js App Router (13+). For other frameworks, you can use the core utilities but will need to implement your own API routes.
import { MAX_FILE_SIZE } from "@fydemy/cms";
// Default is 10MB, you can check this constantTo change it, you'll need to implement your own validation layer.
Yes! The package includes file upload functionality. Images can be uploaded and stored in /public/uploads (local) or via GitHub API (production).
Since content is stored in your GitHub repository (in production), it's automatically backed up with full version history. In development, the /public/content directory can be committed to git.
The built-in rate limiter is memory-based and resets on server restart. For production with multiple instances, consider implementing Redis-based rate limiting.
Currently, the package supports a single admin user via environment variables. For multi-user support, you'd need to implement a custom authentication layer.
Check the /apps/dev directory in this repository for a complete example with:
- Login page
- Admin dashboard
- File editor
- File management
Make sure your session secret is long enough. Generate a secure random string:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"The rate limiter is in-memory. For persistent rate limiting, implement Redis storage.
GitHub API has rate limits. For high-traffic sites, consider caching content or using a CDN.
MIT
Contributions welcome! This is a minimal CMS focused on simplicity and maintainability.
Please report security vulnerabilities privately to fydemy@gmail.com or via GitHub security advisories.