Skip to content

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 variables

Note: The create-ereo tailwind template places components, lib, and other shared code inside the app/ directory. You can also place components/ and lib/ 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:

FileURL
app/routes/index.tsx/
app/routes/about.tsx/about
app/routes/posts/index.tsx/posts
app/routes/posts/[id].tsx/posts/:id

Special Files ​

FilePurpose
_layout.tsxLayout wrapper for sibling and nested routes
_error.tsxError boundary for the route segment
_loading.tsxLoading UI for the route segment
_404.tsxCustom 404 / not-found page
_middleware.tsMiddleware for the route segment

Dynamic Routes ​

Use square brackets for dynamic segments:

app/routes/
├── posts/
│   ├── [id].tsx          # /posts/123
│   └── [id]/
│       └── comments.tsx  # /posts/123/comments

Access parameters in your component:

tsx
// 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
tsx
// 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     # /settings

API Routes ​

API routes can be defined in two ways:

Option A: loader/action exports — simpler when you only need GET and one mutation method:

ts
// 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:

ts
// 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 card

Import in routes using the ~/ path alias (configured in tsconfig.json):

tsx
import { Counter } from '~/components/Counter'
import { Footer } from '~/components/Footer'

Or use relative imports:

tsx
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.

tsx
// 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:

tsx
// 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:

ts
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):

json
{
  "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 only

Public Directory ​

Static assets served at the root:

public/
├── favicon.ico      # /favicon.ico
├── robots.txt       # /robots.txt
└── images/
    └── logo.png     # /images/logo.png

Build 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 manifest

Best Practices ​

  1. Keep routes focused - Routes should handle routing concerns (loading data, rendering). Extract business logic to lib/.

  2. Colocate related files - Keep tests, styles, and utilities near the code they relate to.

  3. Use path aliases - Configure ~/ (or @/) to avoid deep relative imports.

  4. Separate concerns - Islands for interactivity, components for UI, lib for logic.

  5. 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/

Released under the MIT License.