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

@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