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
Hihi, back again... I have a quick question for you...
In the example for tanstack you use in memory DB. Is it possible you use an actual DB like you would normally? (so the orm or package you would normally use).
Its kinda hard to find a good (prod) DB solution with effect.