A static site generator that creates a browsable directory listing for files hosted on Cloudflare Pages. Think nginx autoindex, but as pre-generated HTML with modern styling and MDX support.
Use this when:
- Your hosting platform doesn't support autoindex (e.g., Cloudflare Pages)
- You want a modernized, customizable directory listing UI
- You need SEO-friendly file indexes with proper metadata
Don't use this when:
- Your server already has
nginx autoindexor equivalent - You need real-time file listings (this generates static snapshots at build time)
- You're serving files that change frequently without rebuild capability
This is a personal, opinionated solution. It contains hardcoded strings and assumptions specific to the use case. Fork and adapt as needed.
static/
├── public/ # Your files to be listed (not in Git for large deployments)
│ ├── charliez0/ # Example: User 1's files
│ │ ├── avatar.jpg
│ │ └── background.jpg
│ ├── lolifamily/ # Example: User 2's files
│ │ ├── avatar.webp
│ │ └── images/
│ └── _headers # Cloudflare Pages headers (CSP, caching, etc.)
│
├── src/
│ ├── pages/
│ │ ├── [...path].astro # Dynamic route: generates a page for each directory
│ │ ├── 403.astro # Forbidden error page
│ │ ├── 404.astro # Not found error page
│ │ └── robots.txt.ts # SEO: robots.txt generator
│ │
│ ├── content/
│ │ └── docs/
│ │ └── header.md # Optional: per-directory header/footer content
│ │
│ ├── content.config.ts # Content Collections: scans public/ for files + docs
│ │
│ ├── utils/
│ │ └── format.ts # Utilities: byte formatting, date formatting
│ │
│ └── styles/
│ └── global.css # Styling for directory listings
│
├── .github/
│ └── workflows/
│ ├── build.yml # CI/CD: Build and deploy to Cloudflare Pages
│ └── lint.yml # CI/CD: TypeLint and ESLint
│
├── astro.config.ts # Astro configuration
├── package.json # Dependencies and scripts
└── pnpm-lock.yaml # Lockfile
// src/content.config.ts
defineCollection({
loader: () => {
// Recursively scan public/ directory
// Collect file metadata: name, size, modified time
// Calculate directory sizes (post-order traversal)
// Return entries for each directory
},
});Key behavior:
- Scans
public/at build time - Skips hidden files (
.prefix): not shown in listing, but parent still listed if they exist - Skips Astro-ignored files (
_prefix): Astro doesn't copy them to dist/ - Skips symlinks (Git doesn't preserve them)
- Skips empty directories (no content = no listing page)
- Directories with
index.htmlskip listing generation (user page takes precedence) - Hidden directories (
.prefix) generate pages but no parent link (hard-enter only) - Calculates recursive directory sizes
- Sorts: directories first, then alphabetically
// src/pages/[...path].astro
export async function getStaticPaths() {
const allDirs = await getCollection('files');
return allDirs.map(dir => ({
params: { path: dir.id },
props: { items: dir.data.children },
}));
}Generates:
- One HTML page per directory
- Breadcrumb navigation matching URL structure
- File table with size, modification date
- Optional Markdown/MDX header/footer per directory
# .github/workflows/build.yml
- Build static site (Astro)
- Deploy to Cloudflare Workers (optional)
- Deploy to Cloudflare Pages (primary)
- Upload artifacts to GitHub (backup)Result: Pure static HTML served from CDN, zero server-side logic.
pnpm install
pnpm dev # Start dev server
pnpm build # Build static sitepnpm online:pages # Deploy to Cloudflare Pages
pnpm online:workers # Deploy to Cloudflare WorkersCreate Markdown or MDX files in src/content/docs/:
src/content/docs/
├── header.md # Root directory header
├── footer.md # Root directory footer
└── charliez0/
├── header.md # Header for /charliez0/
└── footer.md # Footer for /charliez0/
Both .md and .mdx are supported. Use .md for plain Markdown (faster builds), .mdx if you need JSX components.
Headers are rendered above the file listing, footers below.
Problem: Vite's publicDir copies all files during build, wasting I/O and disk space.
Solution: Disable automatic copying, merge manually.
// astro.config.ts
export default defineConfig({
vite: {
publicDir: false, // Don't copy public/ during build
},
});// package.json
{
"scripts": {
"build:prod": "rm -rf public/_astro && astro build && mv dist/* public/ && rmdir dist",
"deploy": "npm run build:prod && pnpm online:pages"
}
}Workflow:
- Clean old
_astro/artifacts (prevents file count accumulation) - Build HTML to
dist/(only a few MB) - Move
dist/*intopublic/(overwrites old HTML) - Deploy
public/withwrangler(incremental upload, only changed files)
Why this works:
- Content Collection scans
public/before build (metadata is collected) - Build generates HTML without copying GBs of data
wrangler pages deployuploads incrementally (only deltas)- Cloudflare Pages has no total size limit, only per-file limit (25MB)
File Management:
- Keep files in local storage or NAS
- Don't track
public/in Git (add to.gitignore) - Use
wranglerto sync changes (it's smart about incremental uploads)
To mount the app at a subpath:
// astro.config.ts
export default defineConfig({
base: '/files/', // App will be at example.com/files/
});Breadcrumbs will include base segments (this is intentional, mirrors nginx behavior).
This project follows the "good enough" principle:
- Correctness: Post-order traversal, proper sorting, fail-fast builds
- Simplicity: ~360 lines of core code (excluding CSS), minimal abstractions
- Pragmatism: Solves a specific problem (static file listings) without over-engineering
- Personal: Hardcoded for specific use cases, not a general-purpose framework
If you need a full-featured file manager, look elsewhere. This is a directory lister, and it does one thing well.
MIT
Q: Why not use nginx autoindex? A: Cloudflare Pages doesn't support it. This replicates the functionality as static HTML.
Q: Why build-time instead of runtime? A: Cloudflare Pages is free, has unlimited bandwidth, and serves static files fast. Runtime would require Cloudflare Workers (more complex, potential costs).
Q: Why Astro? A: Content Collections API is perfect for this use case: type-safe data loading from any source (local files, APIs, databases), built-in caching for incremental builds, and seamless integration with static page generation. Alternative: write a custom static generator in 200 lines of Node.js, but you'd lose the caching and dev tooling.
Q: Can I use this with R2/S3/remote storage? A: Yes. Astro Content Collections support any data source—just write a custom loader:
// Example: S3 loader (conceptual)
defineCollection({
loader: async () => {
const s3 = new S3Client();
const objects = await s3.listObjectsV2({ Bucket: 'my-bucket' });
// Group objects by directory
const directories = new Map();
for (const obj of objects.Contents) {
const dir = path.dirname(obj.Key);
if (!directories.has(dir)) directories.set(dir, []);
directories.get(dir).push({
kind: obj.Key.endsWith('/') ? 'directory' : 'file',
name: path.basename(obj.Key),
size: obj.Size,
modified: obj.LastModified,
});
}
// Return one entry per directory (same structure as local loader)
return Array.from(directories.entries()).map(([dir, children]) => ({
id: dir,
children, // TODO: Sort children (directories first, then alphabetically)
}));
},
});The same caching and build optimizations apply. However, if your files are already in object storage, consider whether you even need a directory listing (most object storage UIs provide this).
Q: The code has hardcoded strings like "zh-CN"! A: Yes. This is a personal project template. Fork it, replace the strings, make it yours.
- Framework: Astro
- Hosting: Cloudflare Pages
- Inspiration: nginx
autoindexmodule