Skip to content

Instantly share code, notes, and snippets.

@kevin-courbet
Last active April 18, 2026 09:45
Show Gist options
  • Select an option

  • Save kevin-courbet/4bebb17f5f2509667e6c6a20cbe72812 to your computer and use it in GitHub Desktop.

Select an option

Save kevin-courbet/4bebb17f5f2509667e6c6a20cbe72812 to your computer and use it in GitHub Desktop.
Effect.ts + Next.js Full-Stack Architecture

Effect.ts + Next.js Full-Stack Architecture

A production-tested architecture for building type-safe Next.js applications with Effect.ts. Combines SSR, Server Actions, React Query, and layered dependency injection.

Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                          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)                 │
└─────────────────────────────────────────────────────────────────────────┘

Directory Structure

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

1. Page Builder (SSR)

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, receives userId
  • .effect() - Public pages
  • .protectedRender() / .render() - Static pages (no data fetching)
  • Auto NotFoundError → 404, other errors → ErrorCard

2. Action Builder (Mutations)

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 } }

3. Server Queries (SSR Source of Truth)

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);
  });
}

4. Queries Wrapper (React Query)

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));
}

5. React Query Hooks

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,
  });
}

6. Service Pattern (Tag + Layer)

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 });
  })
);

7. Repository Pattern

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 });
  })
);

8. Runtime Composition

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>;

9. Error Handling

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;
  });

10. Schema Validation

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,
}) {}

Data Flow Summary

┌──────────────────────────────────────────────────────────────────────────┐
│                              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                        │
└──────────────────────────────────────────────────────────────────────────┘

Key Patterns

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

Testing

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);
  })
);

License

MIT

@kevin-courbet
Copy link
Copy Markdown
Author

Asked my clankers to put up a minimally reproducible repo for you, here it is:

https://github.com/kevin-courbet/effect-nextjs-architecture

Hope that helps !

I have since then migrated from nextjs to Tanstack and Effect v4, but this has been used in Production for well over a year without any issue and I loved the DX ;)

@DaanDeReus
Copy link
Copy Markdown

Nice, you are a godsend!
Do you think its worth switching from next to tanstack start? Been thinking about it...
And is effect V4 different from V3? I use V3 right now also...

@kevin-courbet
Copy link
Copy Markdown
Author

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.

@DaanDeReus
Copy link
Copy Markdown

DaanDeReus commented Mar 19, 2026

Okay this is so helpfull. Thanks a lot. Just last question. Can you perhaps ask your clankers one last time to make a example repo for Tanstack Start + EffectTs?
I can send a contribution via PayPal or sum if you can do it. Would love to see some 'production' (or ish) code in tanstack.

Edit: Reason i am asking this is i am going to make a large app soon. But i want some decent starting point in terms of libraries you know. So i dont have to refactor/rebuild the entire thing halfway through.
And since you say you are building a SaaS. It would be perfect to see a small example of how you use the frameworks.

Thanks for everything!

@kevin-courbet
Copy link
Copy Markdown
Author

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

@DaanDeReus
Copy link
Copy Markdown

You are amazing! Thanks

@DaanDeReus
Copy link
Copy Markdown

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.

@kevin-courbet
Copy link
Copy Markdown
Author

kevin-courbet commented Apr 16, 2026 via email

@DaanDeReus
Copy link
Copy Markdown

DaanDeReus commented Apr 18, 2026

Amazing, thanks!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment