Project Structure
EreoJS uses conventions to minimize configuration. Understanding the project structure helps you organize your code effectively.
Directory Layout
A typical EreoJS project (as generated by create-ereo) follows this structure:
my-app/
├── app/
│ ├── components/ # Shared React components
│ │ ├── Counter.tsx # Interactive component ('use client')
│ │ ├── Navigation.tsx # Nav bar
│ │ └── Footer.tsx # Footer
│ ├── lib/ # Utility functions, types, data helpers
│ │ ├── data.ts
│ │ └── types.ts
│ └── routes/ # File-based routes
│ ├── _layout.tsx # Root layout
│ ├── _error.tsx # Global error boundary
│ ├── _404.tsx # Custom 404 page
│ ├── index.tsx # Home page (/)
│ ├── about.tsx # /about
│ ├── contact.tsx # /contact (with form action)
│ ├── (auth)/ # Route group (no URL segment)
│ │ ├── _layout.tsx # Auth layout
│ │ ├── login.tsx # /login
│ │ └── register.tsx # /register
│ ├── posts/
│ │ ├── _layout.tsx # Posts layout
│ │ ├── index.tsx # /posts
│ │ ├── [id].tsx # /posts/:id (dynamic)
│ │ └── [...slug].tsx # /posts/* (catch-all)
│ └── api/
│ └── users.ts # /api/users (API route)
├── public/ # Static assets (served at /)
├── ereo.config.ts # Framework configuration
├── Dockerfile # Production Docker image
├── package.json
├── tsconfig.json
└── .env # Environment variablesNote: The
create-ereotailwind template places components, lib, and other shared code inside theapp/directory. You can also placecomponents/andlib/at the project root if you prefer — EreoJS does not enforce a specific location for non-route files.
Routes Directory
The app/routes directory defines your application's routes through file system conventions. This is the default location - it can be customized in ereo.config.ts.
Page Routes
Each .tsx file becomes a route:
| File | URL |
|---|---|
app/routes/index.tsx | / |
app/routes/about.tsx | /about |
app/routes/posts/index.tsx | /posts |
app/routes/posts/[id].tsx | /posts/:id |
Special Files
| File | Purpose |
|---|---|
_layout.tsx | Layout wrapper for sibling and nested routes |
_error.tsx | Error boundary for the route segment |
_loading.tsx | Loading UI for the route segment |
_404.tsx | Custom 404 / not-found page |
_middleware.ts | Middleware for the route segment |
Dynamic Routes
Use square brackets for dynamic segments:
app/routes/
├── posts/
│ ├── [id].tsx # /posts/123
│ └── [id]/
│ └── comments.tsx # /posts/123/commentsAccess parameters in your component:
// app/routes/posts/[id].tsx
import type { LoaderArgs } from '@ereo/core'
export async function loader({ params }: LoaderArgs<{ id: string }>) {
const post = await getPost(params.id) // params.id = "123"
return { post }
}Catch-All Routes
Use [...slug] for catch-all routes:
app/routes/
└── docs/
└── [...slug].tsx # /docs/a, /docs/a/b, /docs/a/b/c// app/routes/docs/[...slug].tsx
import type { LoaderArgs } from '@ereo/core'
export async function loader({ params }: LoaderArgs<{ slug: string[] }>) {
// params.slug = ["a", "b", "c"] for /docs/a/b/c
const path = params.slug.join('/')
return { path }
}Route Groups
Parentheses create groups without affecting the URL:
app/routes/
├── (marketing)/
│ ├── _layout.tsx # Marketing layout
│ ├── about.tsx # /about
│ └── pricing.tsx # /pricing
└── (dashboard)/
├── _layout.tsx # Dashboard layout
└── settings.tsx # /settingsAPI Routes
API routes can be defined in two ways:
Option A: loader/action exports — simpler when you only need GET and one mutation method:
// app/routes/api/users.ts
import type { LoaderArgs, ActionArgs } from '@ereo/core'
export async function loader({ request }: LoaderArgs) {
const users = await db.select().from(usersTable)
return users // Serialized to JSON
}
export async function action({ request }: ActionArgs) {
const body = await request.json()
const [user] = await db.insert(usersTable).values(body).returning()
return user
}Option B: HTTP method exports — for REST APIs needing per-method control:
// app/routes/api/users.ts
export async function GET({ request }) {
const users = await db.select().from(usersTable)
return Response.json({ users })
}
export async function POST({ request }) {
const body = await request.json()
const [post] = await db.insert(postsTable).values(body).returning()
return Response.json(post, { status: 201 })
}Both approaches are valid. Use loader/action for page routes that render components. Use HTTP method exports for pure API endpoints. See Data Loading for details on all three approaches.
Components Directory
Shared components live inside app/components/ (the default convention used by create-ereo):
app/components/
├── Counter.tsx # Interactive island ('use client')
├── Navigation.tsx # Navigation bar ('use client')
├── Footer.tsx # Footer
└── PostCard.tsx # Blog post cardImport in routes using the ~/ path alias (configured in tsconfig.json):
import { Counter } from '~/components/Counter'
import { Footer } from '~/components/Footer'Or use relative imports:
import { Counter } from '../components/Counter'Interactive Components (Islands)
Components that need client-side interactivity use the 'use client' directive at the top of the file. This marks them for hydration — only these components ship JavaScript to the browser.
// app/components/Counter.tsx
'use client';
import { useState } from 'react';
export function Counter({ initialCount = 0 }) {
const [count, setCount] = useState(initialCount);
return (
<div>
<button onClick={() => setCount(c => c - 1)}>-</button>
<span>{count}</span>
<button onClick={() => setCount(c => c + 1)}>+</button>
</div>
);
}Use the component directly in a route — no special attributes needed:
// app/routes/index.tsx
import { Counter } from '~/components/Counter';
export default function Home() {
return (
<div>
<h1>Welcome</h1>
<Counter initialCount={0} />
</div>
);
}For more advanced hydration control (lazy loading, viewport-based hydration), see Islands Architecture.
Configuration Files
ereo.config.ts
Framework configuration:
import { defineConfig } from '@ereo/core'
export default defineConfig({
server: {
port: 3000,
host: 'localhost'
},
build: {
target: 'bun',
outDir: '.ereo', // default
minify: true
},
plugins: [
// Add plugins here
]
})tsconfig.json
TypeScript configuration (as generated by create-ereo):
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"paths": {
"~/*": ["./app/*"]
}
},
"include": ["app"]
}Environment Files
.env # Loaded in all environments
.env.local # Local overrides (gitignored)
.env.development # Development only
.env.production # Production onlyPublic Directory
Static assets served at the root:
public/
├── favicon.ico # /favicon.ico
├── robots.txt # /robots.txt
└── images/
└── logo.png # /images/logo.pngBuild Output
After bun run build, the output is placed in the .ereo directory (the default, configurable via build.outDir in ereo.config.ts):
.ereo/
├── server/ # Server bundle
│ ├── index.js # Server entry
│ ├── routes/ # Route modules
│ └── chunks/ # Shared chunks
├── client/ # Client bundles
│ ├── index.js # Client entry
│ ├── islands/ # Island bundles
│ └── chunks/ # Client chunks
├── assets/ # Static assets and CSS
└── manifest.json # Build manifestBest Practices
Keep routes focused - Routes should handle routing concerns (loading data, rendering). Extract business logic to
lib/.Colocate related files - Keep tests, styles, and utilities near the code they relate to.
Use path aliases - Configure
~/(or@/) to avoid deep relative imports.Separate concerns - Islands for interactivity, components for UI, lib for logic.
Organize by feature - For large apps, consider organizing by feature rather than type:
app/
├── routes/
│ ├── index.tsx
│ └── ...
├── features/
│ ├── auth/
│ │ ├── components/
│ │ └── lib/
│ └── posts/
│ ├── components/
│ └── lib/
└── shared/
└── components/