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.