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
No need for the tip, happy to help. They put it together here, check it out: https://github.com/kevin-courbet/tanstack-effect-example