A production-tested architecture for building type-safe Next.js applications with Effect.ts. Combines SSR, Server Actions, React Query, and layered dependency injection.
┌─────────────────────────────────────────────────────────────────────────┐
│ CLIENT (React) │
│ useQuery() ←─── queries.ts ←─── server.ts ←─── Service ←─── Repo │
│ useMutation() ←─ actions.ts ←───────────────── Service ←─── Repo │
└─────────────────────────────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────────────────────────────┐
│ SSR (Page Builder) │
│ page.tsx ─── page.protectedEffect() ─── Service ←─── Repo │
└─────────────────────────────────────────────────────────────────────────┘
↑
┌─────────────────────────────────────────────────────────────────────────┐
│ RUNTIME (ManagedRuntime) │
│ Layer.mergeAll(Services, Repositories, Infrastructure) │
└─────────────────────────────────────────────────────────────────────────┘
app/
├── (app)/(dashboard)/invoices/ # Route-level (colocated)
│ ├── page.tsx # SSR entry point
│ ├── server.ts # Effect queries (SSR source of truth)
│ ├── queries.ts # "use server" wrappers for React Query
│ ├── actions.ts # "use server" mutations
│ └── use-invoices.ts # React Query hooks
│
├── server/features/invoice/ # Domain layer
│ ├── invoice.models.ts # Pure TypeScript types
│ ├── invoice.schemas.ts # Effect.Schema (validation)
│ ├── invoice.service.ts # Business logic (Effect.Tag + Layer)
│ └── invoice.repository.ts # Data access (yields Database)
│
├── server/runtime.ts # ManagedRuntime composition
└── lib/
├── page-builder.tsx # SSR page/layout fluent builder
├── actions.ts # Server action builder
└── errors.ts # Shared error types
Type-safe SSR with automatic auth, param validation, and error handling.
// app/lib/page-builder.tsx
export const page = new PageBuilder();
export const layout = new LayoutBuilder();
// app/(app)/(dashboard)/invoices/[slug]/page.tsx
import { page } from "@/app/lib/page-builder";
import { Schema } from "effect";
const ParamsSchema = Schema.Struct({ slug: Schema.String });
export default page
.params(ParamsSchema)
.protectedEffect(({ userId, params }) =>
Effect.gen(function* () {
const service = yield* InvoiceService;
return yield* service.getBySlug(userId, params.slug);
})
)
.render(({ data, userId }) => (
<InvoicePage invoice={data} />
));Features:
.params(Schema)/.searchParams(Schema)- Validated route params.protectedEffect()- Auth required, receivesuserId.effect()- Public pages.protectedRender()/.render()- Static pages (no data fetching)- Auto NotFoundError → 404, other errors → ErrorCard
Type-safe server actions with schema validation and auth.
// app/lib/actions.ts
export const action = {
schema: (S) => ({
protectedEffect: (fn) => ..., // Validated + auth
effect: (fn) => ..., // Validated, no auth
}),
protectedEffect: (fn) => ..., // Auth only, no validation
};
// app/(app)/(dashboard)/invoices/actions.ts
"use server";
import { action } from "@/app/lib/actions";
import { Schema } from "effect";
const DeleteInvoiceSchema = Schema.Struct({
invoiceId: Schema.Number
});
export const deleteInvoice = action
.schema(DeleteInvoiceSchema)
.protectedEffect(async ({ userId, input }) =>
InvoiceService.delete(userId, input.invoiceId)
);
// Returns: { success: true, data } | { success: false, error: { type, message } }Effect-returning functions for SSR data fetching.
// app/(app)/(dashboard)/invoices/server.ts
import "server-only";
import { Effect } from "effect";
export function getInvoices(userId: UserId) {
return Effect.gen(function* () {
const service = yield* InvoiceService;
return yield* service.getAll(userId);
});
}
export function getInvoiceBySlug(userId: UserId, slug: string) {
return Effect.gen(function* () {
const service = yield* InvoiceService;
return yield* service.getBySlug(userId, slug);
});
}Server action wrappers that run Effects for client-side fetching.
// app/(app)/(dashboard)/invoices/queries.ts
"use server";
import { runtime } from "@/app/server/runtime";
import { getInvoices, getInvoiceBySlug } from "./server";
export async function getInvoicesAction(userId: UserId) {
return runtime.runPromise(getInvoices(userId));
}
export async function getInvoiceBySlugAction(userId: UserId, slug: string) {
return runtime.runPromise(getInvoiceBySlug(userId, slug));
}Client-side data fetching with SSR hydration support.
// app/(app)/(dashboard)/invoices/use-invoices.ts
"use client";
import { useQuery } from "@tanstack/react-query";
import { getInvoicesAction } from "./queries";
export function useInvoices(userId: string, initialData?: Invoice[]) {
return useQuery({
queryKey: ["invoices", userId],
queryFn: () => getInvoicesAction(userId),
initialData,
});
}Business logic with dependency injection via Effect.Tag.
// app/server/features/invoice/invoice.service.ts
import { Effect, Layer } from "effect";
// Interface (R = never, deps resolved in Layer)
type InvoiceServiceInterface = {
readonly getAll: (userId: UserId) => Effect.Effect<Invoice[], DatabaseError>;
readonly getBySlug: (userId: UserId, slug: string) =>
Effect.Effect<Invoice, InvoiceNotFoundError | DatabaseError>;
readonly create: (userId: UserId, data: CreateInvoiceInput) =>
Effect.Effect<Invoice, ValidationError | DatabaseError>;
};
// Tag
export class InvoiceService extends Effect.Tag("@app/InvoiceService")<
InvoiceService,
InvoiceServiceInterface
>() {}
// Layer
export const InvoiceServiceLive = Layer.effect(
InvoiceService,
Effect.gen(function* () {
const repo = yield* InvoiceRepository;
const revalidator = yield* RevalidationService;
const create = (userId, data) =>
Effect.fn("createInvoice")(function* () {
const invoice = yield* repo.create(userId, data);
yield* revalidator.revalidatePaths(["/invoices"]);
return invoice;
});
return InvoiceService.of({ getAll, getBySlug, create });
})
);Data access layer with multi-tenant filtering.
// app/server/features/invoice/invoice.repository.ts
import "server-only";
import { Context, Effect, Layer } from "effect";
import { and, eq } from "drizzle-orm";
export type InvoiceRepository = {
readonly findBySlug: (userId: UserId, slug: string) =>
Effect.Effect<Invoice | null, DatabaseError>;
};
export const InvoiceRepository = Context.GenericTag<InvoiceRepository>(
"@repositories/InvoiceRepository"
);
export const InvoiceRepositoryLive = Layer.effect(
InvoiceRepository,
Effect.gen(function* () {
const db = yield* Database;
const findBySlug = (userId, slug) =>
Effect.tryPromise({
try: () =>
db.query.invoice.findFirst({
where: and(
eq(invoice.slug, slug),
eq(invoice.userId, userId) // Multi-tenant!
),
}),
catch: (e) => new DatabaseError({ operation: "findBySlug", details: e }),
});
return InvoiceRepository.of({ findBySlug });
})
);Layered dependency graph with ManagedRuntime.
// app/server/runtime.ts
import { Layer, ManagedRuntime } from "effect";
// Layer 1: Infrastructure
const InfrastructureLive = Layer.mergeAll(
R2ServiceLive,
EmailClientLive,
OpenAIServiceLive,
QStashServiceLive,
).pipe(Layer.provide(ConfigLive));
// Layer 2: Repositories
const RepositoriesLive = Layer.mergeAll(
InvoiceRepositoryLive,
ContactRepositoryLive,
// ... all repositories
).pipe(Layer.provide(DatabaseLive));
// Layer 3: Services
const ServicesLive = Layer.mergeAll(
InvoiceServiceLive,
ContactServiceLive,
// ... all services
).pipe(
Layer.provide(Layer.mergeAll(
RepositoriesLive,
InfrastructureLive,
ConfigLive,
))
);
// Runtime singleton
export const runtime = ManagedRuntime.make(ServicesLive);
export type RuntimeContext = ManagedRuntime.Context<typeof runtime>;Shared NotFoundError base for automatic 404 handling.
// app/lib/errors.ts
import { Data } from "effect";
// Base class - page builder catches _tag: "NotFoundError" → 404
export abstract class NotFoundError extends Data.TaggedError("NotFoundError")<{
readonly message: string;
}> {}
// Domain errors extend base (share same _tag)
// app/server/features/invoice/invoice.service.ts
export class InvoiceNotFoundError extends NotFoundError {}
// app/server/features/contact/contact.service.ts
export class ContactNotFoundError extends NotFoundError {}
// Usage: throw in service, page builder auto-redirects to 404
const getBySlug = (userId, slug) =>
Effect.gen(function* () {
const invoice = yield* repo.findBySlug(userId, slug);
if (!invoice) {
return yield* Effect.fail(
new InvoiceNotFoundError({ message: `Invoice ${slug} not found` })
);
}
return invoice;
});Effect.Schema for type-safe validation at boundaries.
// app/server/features/invoice/invoice.schemas.ts
import { Schema } from "effect";
// Branded types
export class InvoiceId extends Schema.String.pipe(Schema.brand("InvoiceId")) {}
export class InvoiceSlug extends Schema.String.pipe(Schema.brand("InvoiceSlug")) {}
// Input validation (used in actions.ts)
export class CreateInvoiceInput extends Schema.Class<CreateInvoiceInput>(
"CreateInvoiceInput"
)({
clientId: Schema.Number,
items: Schema.Array(InvoiceItemSchema).pipe(
Schema.filter((items) => items.length >= 1, {
message: () => "At least one item required",
})
),
dueDate: Schema.Date,
}) {}┌──────────────────────────────────────────────────────────────────────────┐
│ SSR FLOW │
├──────────────────────────────────────────────────────────────────────────┤
│ page.tsx │
│ └─► page.protectedEffect(fn) │
│ └─► Effect.gen + yield* Service │
│ └─► Service.method() │
│ └─► yield* Repository │
│ └─► Database query (userId filtered) │
│ └─► Return data to render() │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ CLIENT QUERY FLOW │
├──────────────────────────────────────────────────────────────────────────┤
│ useInvoices(userId) │
│ └─► useQuery({ queryFn: getInvoicesAction }) │
│ └─► queries.ts: runtime.runPromise(getInvoices(userId)) │
│ └─► server.ts: Effect.gen + yield* Service │
│ └─► Service → Repository → DB │
└──────────────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────┐
│ MUTATION FLOW │
├──────────────────────────────────────────────────────────────────────────┤
│ deleteInvoice({ invoiceId: 123 }) │
│ └─► action.schema(S).protectedEffect(fn) │
│ ├─► Validate input with Schema │
│ ├─► Extract userId from headers │
│ └─► fn({ userId, input }) │
│ └─► Service.delete(userId, input.invoiceId) │
│ └─► Repository → DB │
│ └─► Return ApiResponse │
└──────────────────────────────────────────────────────────────────────────┘
| Pattern | Purpose |
|---|---|
| Colocation | Route-level files (server.ts, queries.ts, actions.ts) stay with page |
| Effect.Tag | Type-safe dependency injection without globals |
| Layer composition | Explicit dependency graph, testable |
| NotFoundError inheritance | Domain errors → automatic 404 |
| Multi-tenant by default | All queries filter by userId |
| Schema at boundaries | Validate in actions, trust in services |
import { it, layer } from "@effect/vitest";
// Mock layer
const InvoiceServiceTestLayer = Layer.succeed(
InvoiceService,
InvoiceService.of({
getAll: () => Effect.succeed([mockInvoice]),
getBySlug: () => Effect.succeed(mockInvoice),
})
);
layer(InvoiceServiceTestLayer);
it.effect("returns invoices", () =>
Effect.gen(function* () {
const service = yield* InvoiceService;
const invoices = yield* service.getAll(mockUserId);
expect(invoices).toHaveLength(1);
})
);MIT