npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@tula-tech/nextjs

v0.4.0

Published

Tula's Next.js SDK: a same-origin proxy route handler, middleware that verifies sessions offline, server helpers and the React components for the App Router.

Readme

@tula-tech/nextjs

Tula Auth for the Next.js App Router (15 and 16): the prebuilt components, a server that knows who is signed in, and session cookies that belong to your own origin. See ADR 0029 for the design and examples/nextjs-app-router for a complete app.

bun add @tula-tech/nextjs@latest @tula-tech/react@latest    # or: npm install …

Early release (0.x). The API may change from one release to the next. Install @tula-tech/react at the same version. It talks to a Tula API, which you host yourself (docs/self-host.md); everything else is in the repository.

Set up

TULA_API_URL=https://auth.example.com          # where the Next.js SERVER reaches the API
NEXT_PUBLIC_TULA_PUBLISHABLE_KEY=tula_pk_live_…
TULA_ENVIRONMENT_ID=<environment id>
# TULA_ISSUER=…       only if the API's public URL differs from TULA_API_URL
# TULA_APP_URL=https://app.example.com   recommended in production: the app's public origin
#                     (required behind a proxy that rewrites Host)
# TULA_TRUSTED_PROXY_HOPS=1   proxies in front of this server that append to X-Forwarded-For
#                             (default 0: none is trusted; see "What you must configure")
# TULA_SECRET_KEY=…   only for `stateful` session profiles; never NEXT_PUBLIC_
#                     With it, TULA_API_URL must be https (or localhost); on a private network
#                     you trust, TULA_ALLOW_INSECURE_HTTP=true allows plain http

1. The route handler. The browser talks to this route, never to the API's host.

// app/api/tula/[...tula]/route.ts
import { createTulaHandlers } from '@tula-tech/nextjs/handlers'

export const { GET, POST, PUT, PATCH, DELETE } = createTulaHandlers()

2. The request interceptor. proxy.ts in Next.js 16; in Next.js 15 name the file middleware.ts and the export middleware.

// proxy.ts
import { tulaMiddleware } from '@tula-tech/nextjs/middleware'

export const proxy = tulaMiddleware({ publicRoutes: ['/', '/pricing'] })

export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'] }

3. The provider, with what the server knows, so the first paint is right.

// app/layout.tsx
import { TulaProvider } from '@tula-tech/nextjs'
import { auth } from '@tula-tech/nextjs/server'
import '@tula-tech/react/styles.css'

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const { sessionId } = await auth()
  return (
    <html lang='en'>
      <body>
        <TulaProvider
          publishableKey={process.env.NEXT_PUBLIC_TULA_PUBLISHABLE_KEY ?? ''}
          initialState={sessionId ? { sessionId } : null}
          signInUrl='/sign-in'
          afterSignOutUrl='/'
        >
          {children}
        </TulaProvider>
      </body>
    </html>
  )
}

4. Pages. Components come from @tula-tech/nextjs (they are Client Components); the server helpers from @tula-tech/nextjs/server.

// app/sign-in/page.tsx
import { SignIn } from '@tula-tech/nextjs'
import { safeRedirectPath } from '@tula-tech/nextjs/server'

export default async function Page(props: { searchParams: Promise<{ redirect_url?: string }> }) {
  const { redirect_url } = await props.searchParams
  return <SignIn afterSignInUrl={safeRedirectPath(redirect_url, '/dashboard')} />
}
// app/dashboard/page.tsx
import { auth, currentUser } from '@tula-tech/nextjs/server'

export default async function Dashboard() {
  const { userId } = await auth()
  const user = await currentUser()
  return <p>{user?.email} ({userId})</p>
}

Organizations and has()

Where the environment has organizations (ADR 0065), the access token of a session with an active organization carries the organization, the user's role in it and the role's permissions. On the server:

const { orgId, orgRole, orgPermissions, has } = await auth()
if (!has({ permission: 'org:billing:read' })) {
  redirect('/no-access')
}
has({ role: 'admin' }) // a role instead; one check at a time

has() is an exact match on the keys (no wildcard) and false when no organization is active, for a signed-out visitor and for a check that names neither or both. The three claims are used only as a set that passed the contract's checks (the grammar of the keys, the bounds): a malformed set means no organization, never a partial one. This holds for a hybrid session's token, a stateful session's answer and the header the middleware seals.

Protect a group of routes in the middleware, before anything renders:

// proxy.ts
export const proxy = tulaMiddleware({
  publicRoutes: ['/', '/pricing'],
  access: [
    { routes: '/billing(.*)', permission: 'org:billing:read' },
    { routes: '/admin(.*)', role: 'admin' },
  ],
  forbiddenUrl: '/no-access', // pages go here; API routes and writes get a 403 JSON error
})

A visitor with no session is sent to sign in; one who is signed in and fails the rule is refused. A rule with a wildcard, a key that is not in the grammar, or both or neither of permission and role throws when the middleware is created. Check again in the page or route that serves the data: a matcher is a convenience, not the only guard.

In the browser, useOrganization() and useOrganizationList() (re-exported from @tula-tech/react) read and switch the active organization; a switch refreshes the router so the server components render with the new token.

A large role makes the access token outgrow one cookie. The largest role and custom claims the API accepts give an access token of about 5.3 kB, more than the roughly 4 kB a browser keeps in one cookie. The SDK stores the token in up to two cookies, tula_at and tula_at_1, and joins them before it verifies the whole as one token. Nothing for you to do; do not read or write these cookies yourself. A token too large even for two is not stored, one warning says so, and the session continues on its refresh cookie.

What you must configure on the API

  • Allowed origins. Add the app's origin to the environment's urls.allowedOrigins. The handler forwards the browser's Origin unchanged, so the API's own origin rules decide.

  • The visitor's address: two settings, both needed. The API limits sign-in attempts per IP address, and behind the handler it sees the Next.js server's address unless it is told the visitor's.

    • On the Next.js server, say how the address is known. By default no forwarding header is believed and no address is sent: anyone who can reach the server directly can write an X-Forwarded-For of their own. Set TULA_TRUSTED_PROXY_HOPS (or the trustedProxyHops option) to the number of proxies in front of the Next.js server that append to X-Forwarded-For: 1 behind one load balancer or one platform router, 2 behind a CDN and a load balancer. The address is then the entry that many places from the right; what is left of it came with the request and is ignored. On a platform that states the address in its own header, pass clientIp: (request) => … instead. Too high a number lets a visitor choose their address, and per-IP limits, lockout and the audit log's addresses then mean nothing; when unsure, count low.
    • On the API, set TRUST_PROXY=true, and have the Next.js server reach it directly.

    With either one missing every visitor shares the Next.js server's address, and therefore one per-IP rate limit: a few failed sign-ins by one person can lock everyone out. That is safe but coarse, and in production the server says so once in its log.

  • Callback pages, if you use emailed links or OAuth. Give the provider emailLinkUrl and oauthCallbackUrl (paths of your app), render <EmailLinkCallback /> and <OAuthCallback /> on those pages, leave both routes public in the interceptor (a visitor arrives there signed out), and list their full URLs in the environment's urls.allowedRedirectUrls. Both finish through the handler; an OAuth provider returns the visitor to the API's host first, so the provider's redirect URI stays the API's.

How it behaves

  • Cookies (tula_rt, tula_at, tula_session; __Host--prefixed over https) are HttpOnly, SameSite=Lax, host-only. No script can read a token and nothing is in storage.
  • Access tokens are verified offline against the environment's JWKS: no call per request. A session revoked on the API keeps working here until its token expires (60 seconds by default), then is signed out at the refresh. currentUser() asks the API and notices at once.
  • Clocks must agree. Expiry is judged by the Next.js server's clock against the API's exp (five seconds of tolerance): keep both on NTP.
  • One session per browser. A sign-in replaces whatever the browser held: when the API issues a stateful session's cookie the handler removes the token cookies, and the other way round, so the server never answers for an earlier user.
  • A refresh the API refuses for the session (session.*, auth.user_banned) clears the cookies. A refusal of the request itself (the app's origin is not among the allowed origins, the publishable key is wrong) does not: the request is treated as signed out, the cookies stay, and the server logs once what is likely misconfigured. Warnings go to console.warn, or to onWarning when you pass one; they never contain a token, a key or a cookie.
  • Sizes. The handler passes on request bodies up to 1 MiB (413 beyond, also for a streamed body) and reads JSON answers up to 1 MiB (502 beyond).
  • Refresh happens in the interceptor when the token is missing or about to expire. Requests racing with one refresh token are covered by the API's reuse grace window (10 seconds by default); do not use a profile with refresh.reuseGracePeriod: null behind it. A refresh waits at most 8 seconds whatever timeoutSeconds says, and one that got no answer at all is sent once more at once: both stay inside that window. The single flight is per process: with several Next.js instances, two of them can present the same refresh token, and only the API's reuse grace window keeps the second from being treated as reuse. Flights are keyed by API URL, environment and token, and the one that starts a flight sends its own client address and Origin for everyone who joins it, so the API's per-IP rate-limit counts that first caller.
  • Over plain http, a sibling origin can plant cookies. Without https the cookie names have no __Host- prefix, so a page on another port or subdomain of the same host can write tula_at or tula_at_1 with garbage. That can force a refresh or a sign-out (a denial of service); it cannot forge a session, because every token is verified. Use https in production.
  • x-tula-auth is stripped only where the interceptor runs. The header is sealed with an HMAC bound to the session cookie, but a route outside the interceptor's matcher receives whatever the browser sent. Such a route must not read x-tula-auth as input: call auth().
  • Set TULA_APP_URL to the app's public origin. It decides whether the __Host- cookie names are used and which Origin the handler accepts, the same way in the interceptor, the handler and auth(). Set the variable, not only the appUrl option of the interceptor and the handler: auth() and currentUser() read the environment. Without it the app's origin is worked out from each request, and behind a proxy that ends TLS that needs the proxy to send X-Forwarded-Proto: https: Next.js fills the header in with http when the proxy sent none. In that case (no app URL, no forwarded https) the handler refuses every write with request.origin_not_allowed, so nobody can sign in, and the server logs once why. Cookie names alone have one more rule, the same in all three places: a request that carries one of this package's __Host- cookies is read under the __Host- names even when the forwarded scheme says http, because a browser stores and sends such a cookie over https only. It keeps auth() and the interceptor in agreement; it does not replace the setting. Where that rule chose the names, an answer that sets or clears a cookie also expires the plain-named ones, so a sign-in made over http on the same host (localhost, another port) does not come back after a sign-out.
  • A failed sign-out is not a sign-out. The handler clears the cookies only when the API answered the sign-out; <UserButton> and <UserProfile> then stay on the page and say the session may still be active, with "Try again".
  • stateful session profiles have no token. With TULA_SECRET_KEY the server asks the API on every matched request (one network call each); without it the server treats such a session as signed out.
  • redirect_url is written by the interceptor as a path and must be read through safeRedirectPath, which refuses anything that leaves the origin, including a path that only becomes a host once it is normalised (/.//host).
  • auth() in the root layout makes every route dynamic. For static pages leave initialState out; the state is then loading until the browser has asked.