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
What I've learned from my experience with this is: The old adage "Use the right tool for the right job" can't be truer.
It's a complex answer ;). Especially because, with "Next" comes a lot of things. It tends to push you towards RSC, and SSR. It tends to come together with Vercel (for 99% of people). It comes with its own dev tools.
I think NextJS is great if you have an app that particularly makes effective use of ISR and SSR. I'm thinking e-commerce apps, or apps with a lot of public pages, with static content that needs to be updated on a regular basis.
However, there is something that is absolutely atrocious and which started me looking for alternatives in the first place: The performance of "next dev". The development server is ATROCIOUSLY slow when you get to a large-scale codebase. It was fine in the first 6 months of a SaaS I'm developping then became prohibitively slow - so much I couldn't bear it anymore as it was slowing me down significantly.
Second, I went all in on RSC and SSR. People were touting this as the godsend of DX. However, I realised down the run that honestly, if you are doing things "properly", then eventually you adopt patterns (such as the one presented in this Gist) that anyway do not make use of the "shortcuts" that they invented and are compatible with all frameworks (it's trivial to adapt the above to Tanstack Start for instance, because the principles do not depend on framework implementations. You only have a slim adapter layer to deal with). So the more I got into Effect, the more I started to question why I was using NextJS in the first place.
Third and finally, what was the nail in the coffin for me, was that I realised that every page render involved a 100-200ms delay due to SSR, and that was incompatible with the experience I wanted to give the user (desktop-like). There are a LOT of things that NextJS does to work its way around that, a lot of caching you can do, prefetching and all that jazz, but at the end of the day, the routing is server-side (app directory). There are things that you just can't do with server-side routing, and this is what led me to Tanstack Start and what it does well. It is isomorphic so you can have true client-side navigation with absolutely zero delay whatsover, while still having clear boundaries between client and server and using SSR.
To me, Tanstack Start is just literally a better alternative to NextJS EXCEPT if your app leans heavily into ISR etc.
If you have an "app" app, then I'd stay clear of NextJS in my opinion.
Don't get me wrong -- you will get a viable app with NextJS (my project was succesful on it). But I no longer think it is the right tool for most apps out there, despite them telling you otherwise.
Is NextJS a good DX? Early on yes, then it cracks at its seams.
Is Vercel a great DX as far as hosting a web app goes? Yes. It comes with batteries included (logs, branch deployments, ...) But do you need it? Depends.
I used to buy in the "let me take care of that for you and just focus on coding" thing that some providers like Vercel do. However, I've now come to realize that they are merchants of complexity. And the reality is, you're better off taking the time to do things yourself, and start simple. But that's a slightly different subject ;)
TL;DR: Nothing wrong with NextJS ultimately, but I wouldn't recommend it anymore. Tanstack is slightly less polished, but it's been LIGHT AND DAY for me in terms of DX, and app performance, so I'm happy with the switch !
Re Effect: I can't comment on it too much because V4 does bring about a buncha changes, but they are not too drastically different from V3 and V4 is still in Beta. Don't worry too much about it, I've done the migration from V3 to V4 and it took me like one hour. The patterns are still mostly the same, save one or two api that changed but it's easy if you're already on V3.