Skip to content

Typed helpers for building Next.js App Router pages, layouts, server components, and actions with Effect.

License

Notifications You must be signed in to change notification settings

mcrovero/effect-nextjs

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

85 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

@mcrovero/effect-nextjs

npm version license: MIT

Write your Next.js App Router pages, layouts, server components, routes, and actions with Effect without losing the Next.js developer experience.

  • End-to-end Effect: Write your app logic as Effect while keeping familiar Next.js ergonomics.
  • Composable middlewares: Add auth and other cross‑cutting concerns in a clear, reusable way.
  • Works with Next.js: redirect, notFound, and other control‑flow behaviors just work. Also provides Effect versions of the utilities.
  • Safe routing: Decode route params and search params with Effect Schema for safer handlers.
  • Cache‑ready: Plays well with @mcrovero/effect-react-cache (react-cache wrapper) across pages, layouts, and components.

Warning

This library is in early alpha and is not ready for production use.

Getting Started

  1. Install effect and the library in an existing Next.js 15+ application
pnpm add @mcrovero/effect-nextjs effect

or create a new Next.js application first:

pnpx create-next-app@latest
  1. Define Next effect runtime
// lib/runtime.ts
import { Next } from "@mcrovero/effect-nextjs"
import { Layer } from "effect"

const AppLive = Layer.empty // Your stateless layers
export const BasePage = Next.make("BasePage", AppLive)

Warning

It is important that all layers passed to the runtime are stateless. If you need to use a stateful layer like a database connection read below. (see Stateful layers)

  1. Write your first page
// app/page.tsx
import { BasePage } from "@/lib/runtime"
import { Effect } from "effect"

const HomePage = Effect.fn("HomePage")(function* () {
  return <div>Hello World</div>
})

export default BasePage.build(HomePage)

When using Effect.fn you'll get automatic telemetry spans for the page load and better stack traces.

  1. Define a middleware
// lib/auth-runtime.ts
import { Next, NextMiddleware } from "@mcrovero/effect-nextjs"
import { Layer, Schema } from "effect"
import * as Context from "effect/Context"
import * as Effect from "effect/Effect"

export class CurrentUser extends Context.Tag("CurrentUser")<CurrentUser, { id: string; name: string }>() {}

// Middleware that provides CurrentUser and can fail with a string
export class AuthMiddleware extends NextMiddleware.Tag<AuthMiddleware>()("AuthMiddleware", {
  provides: CurrentUser,
  failure: Schema.String
}) {}

// Live implementation for the middleware
export const AuthLive = Layer.succeed(
  AuthMiddleware,
  AuthMiddleware.of(() => Effect.succeed({ id: "123", name: "Ada" }))
)

// Create a typed page handler
export const AuthenticatedPage = Next.make("BasePage", AuthLive).middleware(AuthMiddleware)
  1. Use the middleware in a page and get the CurrentUser value
// app/page.tsx
import { AuthenticatedPage, CurrentUser } from "@/lib/auth-runtime" // wherever you defined it
import { Effect } from "effect"

const HomePage = () =>
  Effect.gen(function* () {
    const user = yield* CurrentUser
    return <div>Hello {user.name}</div>
  })

export default AuthenticatedPage.build(HomePage)

You can provide as many middlewares as you want.

const HomePage = AuthenticatedPage.middleware(LocaleMiddleware).middleware(TimezoneMiddleware).build(HomePage)

Warning

The middleware order is important. The middleware will be executed in the order they are provided from left to right.

Effect Next.js utilities

When you need to use nextjs utilities like redirect, notFound, etc. you need to call them using Effect.sync. Code with side effects should always be lazy in Effect.

import { Effect } from "effect"
import { redirect } from "next/navigation"

const HomePage = Effect.fn("HomePage")(function* () {
  yield* Effect.sync(() => redirect("/somewhere"))
})
export default BasePage.build(HomePage)

Or you can use the Effect version of the utility functions like Redirect or NotFound.

import { Effect } from "effect"
import { Redirect } from "@mcrovero/effect-nextjs/Navigation"

const HomePage = Effect.fn("HomePage")(function* () {
  yield* Redirect("/somewhere")
})
export default BasePage.build(HomePage)

Navigation:

import { Redirect, PermanentRedirect, NotFound } from "@mcrovero/effect-nextjs/Navigation"

const HomePage = Effect.fn("HomePage")(function* () {
  yield* Redirect("/somewhere")
  yield* PermanentRedirect("/somewhere")
  yield* NotFound
})

Cache:

import { RevalidatePath, RevalidateTag } from "@mcrovero/effect-nextjs/Cache"

const HomePage = Effect.fn("HomePage")(function* () {
  yield* RevalidatePath("/")
  yield* RevalidateTag("tag")
})

Headers:

import { Headers, Cookies, DraftMode } from "@mcrovero/effect-nextjs/Headers"
Ø
const HomePage = Effect.fn("HomePage")(function* () {
  const headers = yield* Headers
  const cookies = yield* Cookies
  const draftMode = yield* DraftMode
})

Params and Search Params

You should always validate the params and search params with Effect Schema.

import { BasePage } from "@/lib/runtime"
import { decodeParamsUnknown, decodeSearchParamsUnknown } from "@mcrovero/effect-nextjs/Params"
import { Effect, Schema } from "effect"

const HomePage = Effect.fn("HomePage")((props) =>
  Effect.all([
    decodeParamsUnknown(Schema.Struct({ id: Schema.optional(Schema.String) }))(props.params),
    decodeSearchParamsUnknown(Schema.Struct({ name: Schema.optional(Schema.String) }))(props.searchParams)
  ]).pipe(
    Effect.map(([params, searchParams]) => (
      <div>
        Id: {params.id} Name: {searchParams.name}
      </div>
    )),
    Effect.catchTag("ParseError", () => Effect.succeed(<div>Error decoding params</div>))
  )
)

export default BasePage.build(HomePage)

Wrapped middlewares

You can use wrapped middlewares (wrap: true) to run before and after the handler.

import * as Context from "effect/Context"
import * as Effect from "effect/Effect"
import { Layer, Schema } from "effect"
import { Next, NextMiddleware } from "@mcrovero/effect-nextjs"

export class CurrentUser extends Context.Tag("CurrentUser")<CurrentUser, { id: string; name: string }>() {}

export class Wrapped extends NextMiddleware.Tag<Wrapped>()("Wrapped", {
  provides: CurrentUser,
  failure: Schema.String,
  wrap: true
}) {}

const WrappedLive = Layer.succeed(
  Wrapped,
  Wrapped.of(({ next }) =>
    Effect.gen(function* () {
      yield* Effect.log("before")
      // pre logic...
      const out = yield* Effect.provideService(next, CurrentUser, { id: "u1", name: "Ada" })
      // post logic...
      yield* Effect.log("after")
      return out
    })
  )
)

const AppLive = Layer.mergeAll(WrappedLive)
const Page = Next.make("Home", AppLive).middleware(Wrapped)

Stateful layers

When using a stateful layer there is no clean way to dispose it safely on HMR in development. You should define the Next runtime globally using globalValue from effect/GlobalValue.

import { Next } from "@mcrovero/effect-nextjs"
import { Effect, ManagedRuntime } from "effect"
import { globalValue } from "effect/GlobalValue"

export class StatefulService extends Effect.Service<StatefulService>()("app/StatefulService", {
  scoped: Effect.gen(function* () {
    yield* Effect.log("StatefulService scoped")
    yield* Effect.addFinalizer(() => Effect.log("StatefulService finalizer"))
    return {}
  })
}) {}

export const statefulRuntime = globalValue("BasePage", () => {
  const managedRuntime = ManagedRuntime.make(StatefulService.Default)
  process.on("SIGINT", () => {
    managedRuntime.dispose()
  })
  process.on("SIGTERM", () => {
    managedRuntime.dispose()
  })
  return managedRuntime
})

Then you can use it directly using Next.makeWithRuntime.

export const BasePage = Next.makeWithRuntime("BasePage", statefulRuntime)

Or you can extract the context you need from the stateful runtime and using it in a stateless layer. This way you'll get HMR for the stateless layer and clean disposal of the stateful runtime.

const EphemeralLayer = Layer.effectContext(statefulRuntime.runtimeEffect.pipe(Effect.map((runtime) => runtime.context)))

export const BasePage = Next.make("BasePage", EphemeralLayer)

Next.js Route Props Helpers Integration

With Next.js 15.5, you can now use the globally available PageProps and LayoutProps types for fully typed route parameters without manual definitions. You can use them with this library as follows:

import * as Effect from "effect/Effect"
import { Next } from "@mcrovero/effect-nextjs"

// Page with typed route parameters
const BlogPage = Effect.fn("BlogHandler")(function* (props: PageProps<"/blog/[slug]">) {
  const { slug } = yield* Effect.promise(() => props.params)
  return (
    <article>
      <h1>Blog Post: {slug}</h1>
      <p>Content for {slug}</p>
    </article>
  )
})

export default Next.make("BlogPage", AppLive).build(BlØogPage)

// Layout with parallel routes support
const DashboardLayout = Effect.fn("DashboardLayout")(function* (props: LayoutProps<"/dashboard">) {
  // Fully typed parallel route slots
  return (
    <div>
      {props.children}
      {props.analytics} {/* Fully typed */}
      {props.team} {/* Fully typed */}
    </div>
  )
})
export default Next.make("DashboardLayout", AppLive).build(DashboardLayout)

See the official documentation: - Next.js 15.5 – Route Props Helpers

OpenTelemetry

Setup nextjs telemetry following official documentation: - OpenTelemetry

Then install @effect/opentelemetry

pnpm add @effect/opentelemetry

Create the tracer layer

import { Tracer as OtelTracer, Resource } from "@effect/opentelemetry"
import { Effect, Layer, Option } from "effect"

export const layerTracer = OtelTracer.layerGlobal.pipe(
  Layer.provide(
    Layer.unwrapEffect(
      Effect.gen(function* () {
        const resource = yield* Effect.serviceOption(Resource.Resource)
        if (Option.isSome(resource)) {
          return Layer.succeed(Resource.Resource, resource.value)
        }
        return Resource.layerFromEnv()
      })
    )
  )
)

and provide it to the Next runtime

export const AppLiveWithTracer = AppLive.pipe(Layer.provideMerge(layerTracer))
export const BasePage = Next.make("BasePage", AppLiveWithTracer)

About

Typed helpers for building Next.js App Router pages, layouts, server components, and actions with Effect.

Topics

Resources

License

Stars

Watchers

Forks

Contributors 2

  •  
  •