Skip to content

Project Structure

Cloudwerk uses a file-based routing system where your directory structure defines your application’s routes. This guide covers all the file conventions you need to know.

A typical Cloudwerk project has this structure:

  • Directoryapp/
    • page.tsx # Home page (/)
    • layout.tsx # Root layout
    • middleware.ts # Global middleware
    • error.tsx # Error boundary
    • not-found.tsx # 404 page
    • Directoryabout/
      • page.tsx # About page (/about)
    • Directoryusers/
      • page.tsx # Users list (/users)
      • Directory[id]/
        • page.tsx # User profile (/users/:id)
        • Directoryedit/
          • page.tsx # Edit user (/users/:id/edit)
    • Directoryapi/
      • Directoryhealth/
        • route.ts # Health endpoint (/api/health)
      • Directoryusers/
        • route.ts # Users API (/api/users)
        • Directory[id]/
          • route.ts # User API (/api/users/:id)
    • Directoryauth/ # Authentication config
      • config.ts # Auth configuration
      • callbacks.ts # Auth callbacks
      • rbac.ts # Role definitions
      • Directoryproviders/
        • github.ts # OAuth provider
        • credentials.ts # Email/password
    • Directoryqueues/ # Queue consumers
      • email.ts # Email queue
      • notifications.ts # Notifications queue
    • Directoryservices/ # Service definitions
      • Directoryemail/
        • service.ts # Email service
      • Directorypayments/
        • service.ts # Payments service
    • Directoryobjects/ # Durable Objects
      • counter.ts # Counter DO
      • chat-room.ts # Chat room DO
    • Directorytriggers/ # Event triggers
      • daily-cleanup.ts # Scheduled trigger
      • stripe-webhook.ts # Webhook trigger
  • Directorypublic/ # Static assets
  • cloudwerk.config.ts # Configuration
  • wrangler.toml # Cloudflare config
  • package.json
  • tsconfig.json

The page.tsx file defines a publicly accessible page at that route:

// app/about/page.tsx -> /about
export default function AboutPage() {
return <h1>About Us</h1>;
}

Pages can export a loader() function for server-side data fetching:

export async function loader({ params, context }: LoaderArgs) {
return { data: await fetchData() };
}
export default function Page({ data }: PageProps & { data: Data }) {
return <div>{/* render data */}</div>;
}

The layout.tsx file wraps pages and nested layouts:

// app/layout.tsx
export default function RootLayout({ children }: LayoutProps) {
return (
<html>
<body>{children}</body>
</html>
);
}

Layouts can also have their own loader() functions:

import { getUser, getSession } from '@cloudwerk/auth'
export async function loader() {
const user = getUser()
const session = getSession()
return { user, session }
}
export default function DashboardLayout({ children, user }: LayoutProps & { user: User | null }) {
return (
<div>
<Sidebar user={user} />
<main>{children}</main>
</div>
);
}

The route.ts file defines API endpoints that handle HTTP methods:

// app/api/users/route.ts
import { DB } from '@cloudwerk/core/bindings'
import { json } from '@cloudwerk/core'
export async function GET() {
const { results: users } = await DB.prepare('SELECT * FROM users').all()
return json(users)
}
export async function POST(request: Request) {
const body = await request.json()
const id = crypto.randomUUID()
await DB
.prepare('INSERT INTO users (id, name, email) VALUES (?, ?, ?)')
.bind(id, body.name, body.email)
.run()
return json({ id, ...body }, { status: 201 })
}

Import bindings directly from @cloudwerk/core/bindings for clean, ergonomic access. Run cloudwerk bindings generate-types to enable TypeScript autocomplete.

Supported methods: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS

The middleware.ts file runs before route handlers:

// app/middleware.ts
import type { Middleware } from '@cloudwerk/core';
export const middleware: Middleware = async (request, next) => {
// Run before the route handler
console.log(`${request.method} ${request.url}`);
const response = await next();
// Run after the route handler
return response;
};

Middleware is inherited by child routes. Place middleware at any level:

  • Directoryapp/
    • middleware.ts # Runs for all routes
    • Directoryadmin/
      • middleware.ts # Runs for /admin/* routes
      • page.tsx

The error.tsx file handles errors in that route segment:

// app/error.tsx
import type { ErrorBoundaryProps } from '@cloudwerk/core';
export default function ErrorPage({ error, errorType }: ErrorBoundaryProps) {
return (
<div>
<h1>Something went wrong</h1>
<p>{error.message}</p>
{error.digest && <p>Error ID: {error.digest}</p>}
<a href="/">Go home</a>
</div>
);
}

The not-found.tsx file handles 404 errors:

// app/not-found.tsx
export default function NotFoundPage() {
return (
<div>
<h1>404 - Page Not Found</h1>
<a href="/">Go home</a>
</div>
);
}

Cloudwerk uses convention directories for authentication, queues, services, durable objects, and triggers. These are automatically discovered and registered.

Authentication configuration files:

  • Directoryapp/auth/
    • config.ts # Main auth configuration
    • callbacks.ts # Lifecycle callbacks
    • pages.ts # Custom page paths
    • rbac.ts # Role and permission definitions
    • Directoryproviders/
      • github.ts # OAuth provider
      • google.ts # OIDC provider
      • credentials.ts # Email/password
      • email.ts # Magic link
// app/auth/config.ts
import { defineAuthConfig } from '@cloudwerk/auth/convention'
export default defineAuthConfig({
basePath: '/auth',
session: {
strategy: 'database',
maxAge: 30 * 24 * 60 * 60,
},
})
// app/auth/providers/github.ts
import { defineProvider, github } from '@cloudwerk/auth/convention'
export default defineProvider(
github({
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
})
)

Queue consumers are defined as individual files:

  • Directoryapp/queues/
    • email.ts # email queue
    • image-processing.ts # imageProcessing queue
    • notifications.ts # notifications queue

File names are converted to camelCase queue names.

// app/queues/email.ts
import { defineQueue } from '@cloudwerk/queue'
interface EmailMessage {
to: string
subject: string
body: string
}
export default defineQueue<EmailMessage>({
async process(message) {
await sendEmail(message.body)
message.ack()
},
})

Services are defined in named directories:

  • Directoryapp/services/
    • Directoryemail/
      • service.ts # Required: service definition
      • utils.ts # Optional: helper utilities
    • Directorypayments/
      • service.ts
    • Directoryanalytics/
      • service.ts

Directory names are converted to camelCase service names (email/email, user-management/userManagement).

// app/services/email/service.ts
import { defineService } from '@cloudwerk/service'
export default defineService({
methods: {
async send({ to, subject, body }) {
const response = await fetch('https://api.resend.com/emails', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.env.RESEND_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ to, subject, html: body }),
})
return response.json()
},
},
})

Durable Objects are defined as individual files:

  • Directoryapp/objects/
    • counter.ts # Counter DO
    • chat-room.ts # ChatRoom DO
    • game-session.ts # GameSession DO

File names are converted to PascalCase class names.

// app/objects/counter.ts
import { defineDurableObject } from '@cloudwerk/durable-object'
interface CounterState {
count: number
}
export default defineDurableObject<CounterState>({
init: async (ctx) => ({ count: 0 }),
methods: {
async increment(ctx, amount: number = 1) {
ctx.setState({ count: ctx.state.count + amount })
await ctx.storage.put('state', ctx.state)
return ctx.state.count
},
async getCount(ctx) {
return ctx.state.count
},
},
})

Event triggers are defined as individual files:

  • Directoryapp/triggers/
    • daily-cleanup.ts # Scheduled trigger
    • stripe-webhook.ts # Webhook trigger
    • image-uploaded.ts # R2 trigger
    • support-email.ts # Email trigger
// app/triggers/daily-cleanup.ts
import { defineTrigger } from '@cloudwerk/trigger'
export default defineTrigger({
source: {
type: 'scheduled',
cron: '0 0 * * *', // Daily at midnight
},
async handle(event, ctx) {
await ctx.env.DB.exec('DELETE FROM sessions WHERE expires_at < datetime("now")')
},
})
// app/triggers/stripe-webhook.ts
import { defineTrigger } from '@cloudwerk/trigger'
import { stripeVerifier } from '@cloudwerk/trigger/verifiers'
export default defineTrigger({
source: {
type: 'webhook',
path: '/webhooks/stripe',
verifier: stripeVerifier({
secret: process.env.STRIPE_WEBHOOK_SECRET!,
}),
},
async handle(event, ctx) {
const stripeEvent = event.payload
if (stripeEvent.type === 'checkout.session.completed') {
await fulfillOrder(stripeEvent.data.object)
}
},
})

Use brackets for dynamic segments:

app/users/[id]/page.tsx -> /users/:id

Access params in your loader or component:

export async function loader({ params }: LoaderArgs) {
// params.id is the dynamic value
return { user: await getUser(params.id) };
}

Use [...slug] to catch all subsequent segments:

app/docs/[...slug]/page.tsx -> /docs/*
export async function loader({ params }: LoaderArgs) {
// params.slug is an array like ['guides', 'routing']
const path = params.slug.join('/');
return { doc: await getDoc(path) };
}

Use [[...slug]] for optional catch-all:

app/shop/[[...categories]]/page.tsx
-> /shop
-> /shop/electronics
-> /shop/electronics/phones

Use (groupName) to organize routes without affecting the URL:

  • Directoryapp/
    • Directory(marketing)/
      • page.tsx # /
      • Directoryabout/
        • page.tsx # /about
    • Directory(dashboard)/
      • layout.tsx # Dashboard layout
      • Directorydashboard/
        • page.tsx # /dashboard
      • Directorysettings/
        • page.tsx # /settings

You can colocate components, utilities, and tests alongside your routes:

  • Directoryapp/
    • Directoryusers/
      • page.tsx
      • UserCard.tsx # Component (not a route)
      • user.utils.ts # Utility (not a route)
      • page.test.ts # Test (not a route)

Only page.tsx, layout.tsx, route.ts, and middleware.ts are treated as routes.