State of JavaScript 2025

JavaScript Devs, We Hear You

Every year, the State of JavaScript survey asks developers what's broken. Every year, the same pain points rise to the top.

The answer isn't more tools. It's cohesion.

We've stared at lists like this for a decade. And here's what we've realized, most of these aren't tooling problems. They're cohesion problems.

The JavaScript ecosystem has world-class tools. Best-in-class ORMs. Blazing-fast routers. Type-safe validators. Flexible auth libraries. The problem is, they were never designed to work together.

It's like IKEA furniture from five different collections. Every piece is beautifully engineered. But nothing fits. You spend hours shimming, adapting, writing glue code. And the result still creaks.

Some frameworks got type safety right, but left out features. Some have features, but feel like enterprise ceremony. There are excellent auth libraries, but they need excessive plumbing.

Checkpoint 1 — Type Safety

Type safety is easy when you own the entire stack.

When one team designs the router, the validator, the ORM, the mailer, the test client, they can make sure types flow through all of them. No adapters. No type gymnastics. No as unknown as Whatever

That's the advantage of cohesion. AdonisJS doesn't just support TypeScript, it was rewritten from the ground up in 2020 with types at the core of every design decision.

Environment Variables

Access a variable that doesn't exist or misspell the key, and TypeScript catches it before you run the app.

config/database.ts
const dbUrl = Env.get('DATABASE_URL')

// Typo? Immediate error.
const bad = Env.get('DATABSE_URL')
Argument of type '"DATABSE_URL"' is not assignable to parameter of type '"DATABASE_URL"'.2345
Argument of type '"DATABSE_URL"' is not assignable to parameter of type '"DATABASE_URL"'.
Schema validation

Define your validation schema once. The validated output is automatically typed to match, with no manual interface definitions.

validators/post.ts
const schema = vine.object({
  title: vine.string(),
  tags: vine.array(vine.string())
})

const data = await request.validateUsing(schema)
const data: { title: string; tags: string[]; }
Event emitter

Events are registered with their payload types. Emit an event that doesn't exist or pass the wrong shape, and you get a compile error.

services/user.ts
// Event 'user:registerd' doesn't exist
emitter.emit('user:registerd', user)
Argument of type '"user:registerd"' is not assignable to parameter of type '"user:registered" | "user:deleted"'.2345
Argument of type '"user:registerd"' is not assignable to parameter of type '"user:registered" | "user:deleted"'.
Redirects

Redirect to a named route. If the route doesn't exist or you pass the wrong parameters, TypeScript tells you immediately.

controllers/posts.ts
// Route 'users.shwo' doesn't exist
response.redirect().toRoute('users.shwo')
Argument of type '"users.shwo"' is not assignable to parameter of type '"users.index" | "users.show" | "users.create"'.2345
Argument of type '"users.shwo"' is not assignable to parameter of type '"users.index" | "users.show" | "users.create"'.
API Tests

Test your endpoints with a typed HTTP client. Request bodies, responses, and status codes are all checked at compile time.

tests/posts.spec.ts
const response = await client
  .visit('users.store')
  .json({
    fullName: 'Harminder Virk',
    email: 'foo@adonisjs.com',
    password: 'secret'
  })

response.body()
(method) ApiResponse.body(): { data: { id: number; fullName: string; email: string; password: string; }; }
response.assertStatus(200) response.assertBodyContains({ id: 1 })
Serializers

Define how your models transform to JSON. The serialized shape becomes a type you can use on the frontend.

transformers/user.ts
// BACKEND
class UsersController {
  show() {
    return UserTransformer.transform(user)
  }
}

// FRONTEND
// Reference transformers data as generated types
import type { Data } from '@generated/types'

type User = Data.User
type User = { id: number; name: string; email: string; createdAt: string; }
Link and Form components

Frontend components that reference your backend routes by name. Refactor a route, and every broken link surfaces as a type error.

pages/users.tsx
// Missing required param 'id'
<Link route="users.show">Profile</Link>
Property 'routeParams' is missing in type '{ route: "users.show"; }' but required in type '{ route: "users.show"; routeParams: { id: number; }; children?: any; }'.2741
Property 'routeParams' is missing in type '{ route: "users.show"; }' but required in type '{ route: "users.show"; routeParams: { id: number; }; children?: any; }'.
API client

Generate a fully typed client from your routes. Plug it into TanStack Query, SWR, or use it directly. End-to-end type safety.

hooks/useUser.ts
// Direct fetch call
const user = await client.api.users.show({
  params: { id: 1 },
  query: { include: 'posts' }
})
user
const user: { id: number; name: string; email: string; posts: { id: number; title: string; content: string; }[]; }
// TanStack integration const { data } = useQuery( api.users.show.queryOptions({ id }) )
Checkpoint 2 — Authentication

Authentication without the plumbing.

Authentication isn't hard. We know how to hash passwords, manage sessions, issue tokens, and protect routes.

What's hard is the wiring. You pick an auth library. It needs an adapter for your ORM. The session store needs to work with your HTTP framework. The user object shape doesn't match your model. You write glue code. Then more glue code. A week passes.

In a cohesive system, this disappears.

npm i @adonisjs/auth
node ace configure @adonisjs/auth

# ❯ Select authentication guard
#   › Session (for web apps)
#     Access Tokens (for APIs)
#
# ❯ Select user provider
#   › Lucid (database)
#
# ✓ Created config/auth.ts
# ✓ Updated app/models/user.ts
# ✓ Registered auth middleware
import User from '#models/user'
import { loginValidator } from '#validators/auth'
import { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = await request.validateUsing(loginValidator)
    
    const user = await User.verifyCredentials(email, password)
    await auth.use('web').login(user)
    
    return response.redirect('/dashboard')
  }

  async show({ auth }: HttpContext) {
    const user = auth.getUserOrFail()
    const posts = await user.related('posts').query()
    
    return { user, posts }
  }
}
import User from '#models/user'
import { loginValidator } from '#validators/auth'
import { HttpContext } from '@adonisjs/core/http'

export default class TokensController {
  async store({ request }: HttpContext) {
    const { email, password } = await request.validateUsing(loginValidator)
    
    const user = await User.verifyCredentials(email, password)
    const token = await User.accessTokens.create(user)
    
    return {
      type: 'bearer',
      token: token.value!.release(),
    }
  }

  async show({ auth }: HttpContext) {
    const user = auth.getUserOrFail()
    return { user }
  }
}
import { middleware } from '#start/kernel'
import { controllers } from '#generated/controllers'
import router from '@adonisjs/core/services/router'

router
  .group(() => {
    router.resource('posts', controllers.posts)
  })
  .use(middleware.auth({ guards: ['web'] }))

router
  .group(() => {
    router.resource('posts', controllers.api.posts)
  })
  .prefix('api')
  .use(middleware.auth({ guards: ['api'] }))
import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'

export default class GithubController {
  async redirect({ ally }: HttpContext) {
    return ally.use('github').redirect()
  }

  async callback({ ally, auth, response }: HttpContext) {
    const gh = ally.use('github')
    const ghUser = await gh.user()

    const user = await User.firstOrCreate(
      { email: ghUser.email },
      { name: ghUser.name, avatar: ghUser.avatarUrl }
    )

    await auth.use('web').login(user)
    return response.redirect('/dashboard')
  }
}
Checkpoint 3 — Simplicity

Simple code that stays simple.

Structure shouldn't require ceremony. You don't need decorators on every method, module registration files, or an IoC container on your face just to wire up a controller.

AdonisJS code reads as simple as Hono or Express, but with the structure and features of a full-stack framework.

Define a job
import { Job } from '@adonisjs/queue'

type ProcessPaymentPayload = {
  orderId: number
  amount: number
  currency: string
}

export default class ProcessPayment extends Job<ProcessPaymentPayload> {
  async execute() {
    const { orderId, amount, currency } = this.payload
    // process payment
  }
}
Dispatch a job
import ProcessPayment from '#jobs/process_payment'

export default class OrdersController {
  async store({ request, response }: HttpContext) {
    const order = await Order.create(request.body())

    await ProcessPayment.dispatch({
      orderId: order.id,
      amount: order.total,
      currency: 'USD',
    })
    
    return response.created(order)
  }
}
Create emails
import User from '#models/user'
import { BaseMail } from '@adonisjs/mail'
import { urlFor } from '@adonisjs/core/services/url_builder'

export default class VerifyEmailNotification extends BaseMail {
  from = 'noreply@example.com'
  subject = 'Please verify your email address'

  constructor(private user: User) {
    super()
  }

  prepare() {
    const verifyUrl = urlFor('email.verify', {
      token: this.user.verificationToken,
    })

    this.message
      .to(this.user.email)
      .htmlView('emails/verify_email', {
        user: this.user,
        verifyUrl,
      })
  }
}
Create a CLI command
import User from '#models/user'
import WeeklyDigest from '#mails/weekly_digest'
import mail from '@adonisjs/mail/services/main'
import { BaseCommand } from '@adonisjs/core/ace'
import type { CommandOptions } from '@adonisjs/core/types/ace'

export default class SendWeeklyDigest extends BaseCommand {
  static commandName = 'digest:send'
  static description = 'Send weekly digest to all subscribed users'

  static options: CommandOptions = {
    startApp: true,
  }

  async run() {
    const users = await User.query().where('digestEnabled', true)

    for (const user of users) {
      await mail.send(new WeeklyDigest(user))
      this.logger.info(`Sent digest to ${user.email}`)
    }

    this.logger.success(`Sent ${users.length} digests`)
  }
}
Execute command
node ace digest:send
Checkpoint 4 — Ecosystem

The ecosystem around the framework.

A framework is only as good as what surrounds it. Documentation you can actually learn from. Tooling that doesn't fight you. A community that's been building with it for years.

Documentation

Guides, tutorials, and explanations that go beyond API references. The "why" alongside the "how."

Adocasts

Over 400 free video tutorials covering everything from first steps to advanced patterns. A whole learning platform dedicated to AdonisJS.

ESM-first

No dual builds or CommonJS compatibility hacks. We went all-in on ES modules in 2020.

Modern tooling

Hot module reloading for your backend, a CLI that scaffolds and manages your app, and a first-class VS Code extension.

Packages

Auth, ORM, validation, mail, uploads, queues, OTEL, and testing are all first-party packages. Plus a growing collection from the community.

Let's do something different today.

You've assembled the stack before. You've read the integration guides, wired the adapters, written the glue code. You know how that story ends.

This time, try cohesion.

Terminal
$ npm create adonisjs@latest my-app
Select a starter kit
Hypermedia Server-rendered with Edge templates
React Single-page app with Inertia
Vue Single-page app with Inertia
API JSON API with token auth