Skip to content

Instantly share code, notes, and snippets.

@kevin-courbet
Last active November 30, 2025 11:54
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

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